From d81e94717b606cce11e21070a897d3e468c5df12 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 26 Jun 2026 19:05:55 -0500 Subject: [PATCH] Add message reading: body fetch + hardened WebView + flag actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment 4 — read. - ImapClient.fetchBodyMarkingSeen extracts the best body part (HTML preferred, else plain text) and marks the message \Seen; setFlag and deleteMessage (expunge) back the star/read/delete actions. - MailConnectionFactory shares credential/token resolution between sync and reader. - Cached bodies survive sync: schema v3 (isHtml column via a data-preserving Migration 2->3); sync is now insert-new + update-header + delete-absent instead of replace-all, so fetched bodies are not clobbered. - Reader fetches and caches the body on open (marking it read), renders HTML in a hardened WebView (JavaScript off, file/content access off, remote content blocked with an opt-in "Show images") and plain text in selectable Text; star + delete in the app bar; a snippet is derived from the fetched body. - Tests: GreenMail fetchBodyMarkingSeen unit test (body + read flag). assemble/test/ lint green; verified end-to-end on the Android 17 emulator against a local GreenMail server (HTML rendered in the WebView, mark-read, snippet). Co-Authored-By: Claude Opus 4.8 --- README.md | 7 +- .../libremail/data/local/LibreMailDatabase.kt | 2 +- .../org/libremail/data/local/Mappers.kt | 2 + .../org/libremail/data/local/Migrations.kt | 12 ++ .../libremail/data/local/dao/MessageDao.kt | 49 +++-- .../data/local/entity/MessageEntity.kt | 1 + .../data/repository/MailRepositoryImpl.kt | 54 ++++- .../data/sync/MailConnectionFactory.kt | 34 ++++ .../org/libremail/data/sync/MailSyncer.kt | 49 +++-- .../kotlin/org/libremail/di/DatabaseModule.kt | 4 +- .../org/libremail/domain/model/Message.kt | 1 + .../domain/repository/MailRepository.kt | 7 + .../kotlin/org/libremail/mail/ImapClient.kt | 72 +++++++ .../org/libremail/ui/reader/HtmlBody.kt | 74 +++++++ .../org/libremail/ui/reader/ReaderScreen.kt | 187 +++++++++++++----- .../libremail/ui/reader/ReaderViewModel.kt | 46 ++++- app/src/main/res/values/strings.xml | 5 + .../data/repository/MailRepositoryImplTest.kt | 10 +- .../org/libremail/mail/ImapClientTest.kt | 12 ++ 19 files changed, 524 insertions(+), 104 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/data/local/Migrations.kt create mode 100644 app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt diff --git a/README.md b/README.md index 2f7cc02..cd473f9 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ experience with power-user features tucked under an **Advanced Settings** group. > Status: **in development.** Material You shell; **account setup** (Gmail OAuth via > AppAuth/PKCE and generic IMAP/SMTP, with a live connection test and Keystore- -> encrypted credentials); and **IMAP receive** — background sync (WorkManager) that -> fetches your inbox headers into a local Room cache, shown with pull-to-refresh. -> Reading message bodies and sending land in upcoming increments. +> encrypted credentials); **IMAP receive** — background sync (WorkManager) into a local +> Room cache with pull-to-refresh; and **reading** — message bodies fetched on open and +> rendered in a hardened WebView (JavaScript off, remote images blocked by default), +> with mark-read, star, and delete. Composing and sending land next. ## Features (target MVP) diff --git a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt index 57f4872..ccb0e16 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -12,7 +12,7 @@ import org.libremail.data.local.entity.MessageEntity @Database( entities = [AccountEntity::class, MessageEntity::class, CredentialEntity::class], - version = 2, + version = 3, exportSchema = false, ) abstract class LibreMailDatabase : RoomDatabase() { diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt index 54c7466..ac5b470 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -48,6 +48,7 @@ internal fun MessageEntity.toDomain(): Message = Message( subject = subject, snippet = snippet, body = body, + isHtml = isHtml, timestampMillis = timestampMillis, isRead = isRead, isStarred = isStarred, @@ -61,6 +62,7 @@ internal fun FetchedMessage.toEntity(accountId: String): MessageEntity = Message subject = subject, snippet = "", body = "", + isHtml = false, timestampMillis = timestampMillis, isRead = isRead, isStarred = isFlagged, diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt new file mode 100644 index 0000000..bc9feb8 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** v2 -> v3: add the [isHtml] flag to cached messages (preserves existing data). */ +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE messages ADD COLUMN isHtml INTEGER NOT NULL DEFAULT 0") + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt index 6ced8d7..d225324 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt @@ -5,28 +5,51 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import androidx.room.Transaction import kotlinx.coroutines.flow.Flow import org.libremail.data.local.entity.MessageEntity @Dao -abstract class MessageDao { +interface MessageDao { @Query("SELECT * FROM messages ORDER BY timestampMillis DESC") - abstract fun observeAll(): Flow> + fun observeAll(): Flow> @Query("SELECT * FROM messages WHERE id = :id LIMIT 1") - abstract suspend fun getById(id: String): MessageEntity? + suspend fun getById(id: String): MessageEntity? - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun upsertAll(messages: List) + /** Inserts only new messages, leaving existing rows (and their cached bodies) intact. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertNew(messages: List) + + /** Refreshes header/flag columns from the server without touching the cached body. */ + @Query( + "UPDATE messages SET sender = :sender, senderEmail = :senderEmail, subject = :subject, " + + "timestampMillis = :timestampMillis, isRead = :isRead, isStarred = :isStarred WHERE id = :id", + ) + suspend fun updateHeader( + id: String, + sender: String, + senderEmail: String, + subject: String, + timestampMillis: Long, + isRead: Boolean, + isStarred: Boolean, + ) + + @Query("UPDATE messages SET body = :body, isHtml = :isHtml, snippet = :snippet WHERE id = :id") + suspend fun updateBody(id: String, body: String, isHtml: Boolean, snippet: String) + + @Query("UPDATE messages SET isRead = :isRead WHERE id = :id") + suspend fun setRead(id: String, isRead: Boolean) + + @Query("UPDATE messages SET isStarred = :isStarred WHERE id = :id") + suspend fun setStarred(id: String, isStarred: Boolean) + + @Query("DELETE FROM messages WHERE id = :id") + suspend fun deleteById(id: String) @Query("DELETE FROM messages WHERE accountId = :accountId") - abstract suspend fun deleteByAccount(accountId: String) + suspend fun deleteByAccount(accountId: String) - /** Replace an account's cached messages with a freshly fetched set, atomically. */ - @Transaction - open suspend fun replaceAccountMessages(accountId: String, messages: List) { - deleteByAccount(accountId) - upsertAll(messages) - } + @Query("DELETE FROM messages WHERE accountId = :accountId AND id NOT IN (:keepIds)") + suspend fun deleteNotIn(accountId: String, keepIds: List) } diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/MessageEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/MessageEntity.kt index e53a34a..46f5496 100644 --- a/app/src/main/kotlin/org/libremail/data/local/entity/MessageEntity.kt +++ b/app/src/main/kotlin/org/libremail/data/local/entity/MessageEntity.kt @@ -17,6 +17,7 @@ data class MessageEntity( val subject: String, val snippet: String, val body: String, + val isHtml: Boolean = false, val timestampMillis: Long, val isRead: Boolean, val isStarred: Boolean, diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index 46ba186..8e44445 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -1,23 +1,73 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.repository +import jakarta.mail.Flags import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.toDomain +import org.libremail.data.sync.MailConnectionFactory +import org.libremail.domain.model.Account import org.libremail.domain.model.Message import org.libremail.domain.repository.MailRepository +import org.libremail.mail.ImapClient @Singleton class MailRepositoryImpl @Inject constructor( private val messageDao: MessageDao, + private val accountDao: AccountDao, + private val imapClient: ImapClient, + private val connectionFactory: MailConnectionFactory, ) : MailRepository { override fun observeMessages(): Flow> = messageDao.observeAll().map { rows -> rows.map { it.toDomain() } } - override suspend fun getMessage(id: String): Message? = - messageDao.getById(id)?.toDomain() + override suspend fun getMessage(id: String): Message? = messageDao.getById(id)?.toDomain() + + override suspend fun openMessage(id: String): Result = runCatching { + val entity = messageDao.getById(id) ?: error("Message not found") + val account = accountDao.getById(entity.accountId)?.toDomain() + if (account != null) { + val params = connectionFactory.paramsFor(account) + if (entity.body.isBlank()) { + val content = imapClient.fetchBodyMarkingSeen(params, uidOf(id)) + messageDao.updateBody(id, content.body, content.isHtml, snippetOf(content.body)) + messageDao.setRead(id, true) + } else if (!entity.isRead) { + runCatching { imapClient.setFlag(params, uidOf(id), Flags.Flag.SEEN, true) } + messageDao.setRead(id, true) + } + } + messageDao.getById(id)?.toDomain() ?: error("Message not found") + } + + override suspend fun setStarred(id: String, starred: Boolean): Result = runCatching { + messageDao.setStarred(id, starred) // optimistic; next sync reconciles on failure + accountFor(id)?.let { account -> + imapClient.setFlag(connectionFactory.paramsFor(account), uidOf(id), Flags.Flag.FLAGGED, starred) + } + Unit + } + + override suspend fun deleteMessage(id: String): Result = runCatching { + val account = accountFor(id) + messageDao.deleteById(id) // optimistic; reappears on next sync if the server delete failed + account?.let { imapClient.deleteMessage(connectionFactory.paramsFor(it), uidOf(id)) } + Unit + } + + private suspend fun accountFor(id: String): Account? { + val entity = messageDao.getById(id) ?: return null + return accountDao.getById(entity.accountId)?.toDomain() + } } + +/** Message id is ":"; the uid is the trailing segment. */ +private fun uidOf(id: String): String = id.substringAfterLast(':') + +private fun snippetOf(body: String): String = + body.replace(Regex("<[^>]*>"), " ").replace(Regex("\\s+"), " ").trim().take(140) diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt new file mode 100644 index 0000000..88e11f0 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import javax.inject.Inject +import javax.inject.Singleton +import org.libremail.auth.GmailAuthManager +import org.libremail.data.local.toImapParams +import org.libremail.data.security.CredentialStore +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.ImapConnectionParams + +/** Resolves an account's stored credential (refreshing the Gmail token when needed) into IMAP params. */ +@Singleton +class MailConnectionFactory @Inject constructor( + private val credentialStore: CredentialStore, + private val gmailAuthManager: GmailAuthManager, +) { + suspend fun paramsFor(account: Account): ImapConnectionParams { + val stored = credentialStore.loadSecret(account.id) + ?: error("No stored credentials for ${account.email}") + val secret = when (account.authType) { + AuthType.PASSWORD_IMAP -> stored + AuthType.OAUTH_GMAIL -> { + val fresh = gmailAuthManager.freshAccessToken(stored) + if (fresh.authStateJson != stored) { + credentialStore.saveSecret(account.id, fresh.authStateJson) + } + fresh.accessToken + } + } + return account.toImapParams(secret, useXoauth2 = account.authType == AuthType.OAUTH_GMAIL) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt index 96c2b50..bab1158 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt @@ -3,15 +3,11 @@ package org.libremail.data.sync import javax.inject.Inject import javax.inject.Singleton -import org.libremail.auth.GmailAuthManager import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity -import org.libremail.data.local.toImapParams -import org.libremail.data.security.CredentialStore import org.libremail.domain.model.Account -import org.libremail.domain.model.AuthType import org.libremail.mail.ImapClient /** Fetches each account's recent INBOX headers and writes them into Room (the source of truth). */ @@ -19,9 +15,8 @@ import org.libremail.mail.ImapClient class MailSyncer @Inject constructor( private val accountDao: AccountDao, private val messageDao: MessageDao, - private val credentialStore: CredentialStore, private val imapClient: ImapClient, - private val gmailAuthManager: GmailAuthManager, + private val connectionFactory: MailConnectionFactory, ) { /** Syncs every account. Succeeds if at least one account synced (or there are none). */ suspend fun syncAll(): Result { @@ -41,29 +36,29 @@ class MailSyncer @Inject constructor( } private suspend fun syncAccount(account: Account): Result = runCatching { - val secret = resolveSecret(account) - val fetched = imapClient.fetchRecentInbox( - account.toImapParams(secret = secret, useXoauth2 = account.authType == AuthType.OAUTH_GMAIL), - INBOX_LIMIT, - ) - messageDao.replaceAccountMessages(account.id, fetched.map { it.toEntity(account.id) }) - fetched.size - } - - /** Returns a usable IMAP secret, refreshing and re-persisting the OAuth token when needed. */ - private suspend fun resolveSecret(account: Account): String { - val stored = credentialStore.loadSecret(account.id) - ?: error("No stored credentials for ${account.email}") - return when (account.authType) { - AuthType.PASSWORD_IMAP -> stored - AuthType.OAUTH_GMAIL -> { - val fresh = gmailAuthManager.freshAccessToken(stored) - if (fresh.authStateJson != stored) { - credentialStore.saveSecret(account.id, fresh.authStateJson) - } - fresh.accessToken + val params = connectionFactory.paramsFor(account) + val fetched = imapClient.fetchRecentInbox(params, INBOX_LIMIT) + val entities = fetched.map { it.toEntity(account.id) } + if (entities.isEmpty()) { + messageDao.deleteByAccount(account.id) + } else { + // Insert new headers (keeps any already-cached body), refresh header/flag columns, + // then drop messages that no longer exist on the server. + messageDao.insertNew(entities) + entities.forEach { + messageDao.updateHeader( + id = it.id, + sender = it.sender, + senderEmail = it.senderEmail, + subject = it.subject, + timestampMillis = it.timestampMillis, + isRead = it.isRead, + isStarred = it.isStarred, + ) } + messageDao.deleteNotIn(account.id, entities.map { it.id }) } + fetched.size } private companion object { diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index 0247264..7a5a6b9 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -10,6 +10,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton import org.libremail.data.local.LibreMailDatabase +import org.libremail.data.local.MIGRATION_2_3 import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.CredentialDao import org.libremail.data.local.dao.MessageDao @@ -22,7 +23,8 @@ object DatabaseModule { @Singleton fun provideDatabase(@ApplicationContext context: Context): LibreMailDatabase = Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db") - // MVP: no user data worth migrating yet. + .addMigrations(MIGRATION_2_3) + // Safety net for unforeseen schema jumps during early development. .fallbackToDestructiveMigration(dropAllTables = true) .build() diff --git a/app/src/main/kotlin/org/libremail/domain/model/Message.kt b/app/src/main/kotlin/org/libremail/domain/model/Message.kt index 8e34258..d25e7d6 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Message.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Message.kt @@ -9,6 +9,7 @@ data class Message( val subject: String, val snippet: String, val body: String, + val isHtml: Boolean, val timestampMillis: Long, val isRead: Boolean, val isStarred: Boolean, diff --git a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt index 413bf1e..307a1f0 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -12,4 +12,11 @@ interface MailRepository { fun observeMessages(): Flow> suspend fun getMessage(id: String): Message? + + /** Loads a message for reading: fetches+caches the body if missing, and marks it read. */ + suspend fun openMessage(id: String): Result + + suspend fun setStarred(id: String, starred: Boolean): Result + + suspend fun deleteMessage(id: String): Result } diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 20d3b43..5590a58 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -4,6 +4,8 @@ package org.libremail.mail import jakarta.mail.FetchProfile import jakarta.mail.Flags import jakarta.mail.Folder +import jakarta.mail.Multipart +import jakarta.mail.Part import jakarta.mail.Session import jakarta.mail.Store import jakarta.mail.UIDFolder @@ -27,6 +29,12 @@ data class FetchedMessage( val isFlagged: Boolean, ) +/** A message body extracted from the server. */ +data class MessageContent( + val body: String, + val isHtml: Boolean, +) + /** Thin IMAP client over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */ @Singleton class ImapClient @Inject constructor() { @@ -77,6 +85,70 @@ class ImapClient @Inject constructor() { } } + /** Fetches a message body by UID and marks it \Seen on the server. */ + suspend fun fetchBodyMarkingSeen(params: ImapConnectionParams, uid: String): MessageContent = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_WRITE) + try { + val message = (inbox as UIDFolder).getMessageByUID(uid.toLong()) + ?: error("Message $uid not found") + val content = extractBody(message) ?: MessageContent("", isHtml = false) + message.setFlag(Flags.Flag.SEEN, true) + content + } finally { + runCatching { inbox.close(false) } + } + } + } + + suspend fun setFlag(params: ImapConnectionParams, uid: String, flag: Flags.Flag, value: Boolean) = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_WRITE) + try { + (inbox as UIDFolder).getMessageByUID(uid.toLong())?.setFlag(flag, value) + } finally { + runCatching { inbox.close(false) } + } + } + } + + suspend fun deleteMessage(params: ImapConnectionParams, uid: String) = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_WRITE) + try { + (inbox as UIDFolder).getMessageByUID(uid.toLong())?.setFlag(Flags.Flag.DELETED, true) + inbox.expunge() + } finally { + runCatching { inbox.close(false) } + } + } + } + + /** Recursively finds the best body part: HTML preferred, plain text otherwise. */ + private fun extractBody(part: Part): MessageContent? { + if (part.isMimeType("text/html")) return MessageContent(part.content.toString(), isHtml = true) + if (part.isMimeType("text/plain")) return MessageContent(part.content.toString(), isHtml = false) + if (part.isMimeType("multipart/*")) { + val multipart = part.content as? Multipart ?: return null + var plain: MessageContent? = null + for (i in 0 until multipart.count) { + val child = multipart.getBodyPart(i) + if (Part.ATTACHMENT.equals(child.disposition, ignoreCase = true)) continue + val result = extractBody(child) ?: continue + if (result.isHtml) return result + if (plain == null) plain = result + } + return plain + } + return null + } + private inline fun withStore(params: ImapConnectionParams, block: (Store) -> T): T { val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap" val props = Properties().apply { diff --git a/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt new file mode 100644 index 0000000..b8cac5a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reader + +import android.annotation.SuppressLint +import android.content.Intent +import android.graphics.Color +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView + +/** + * Renders an HTML email body in a hardened WebView: JavaScript and file/content access are + * disabled, links open in the system browser, and remote content is blocked until the user + * opts in (tracking-pixel protection). + */ +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun HtmlBody( + html: String, + loadRemoteImages: Boolean, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + AndroidView( + modifier = modifier, + factory = { ctx -> + WebView(ctx).apply { + with(settings) { + javaScriptEnabled = false + allowFileAccess = false + allowContentAccess = false + domStorageEnabled = false + cacheMode = WebSettings.LOAD_NO_CACHE + setSupportZoom(true) + builtInZoomControls = true + displayZoomControls = false + } + setBackgroundColor(Color.TRANSPARENT) + isVerticalScrollBarEnabled = true + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + val url = request?.url ?: return false + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url)) } + return true + } + } + } + }, + update = { webView -> + webView.settings.blockNetworkLoads = !loadRemoteImages + webView.loadDataWithBaseURL(null, wrapHtml(html), "text/html", "UTF-8", null) + }, + ) +} + +private fun wrapHtml(body: String): String = + """ + + + + + + $body + + """.trimIndent() diff --git a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt index 98ccf1d..4ea016e 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt @@ -4,18 +4,23 @@ package org.libremail.ui.reader import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Star import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -24,8 +29,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -35,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.libremail.R +import org.libremail.domain.model.Message @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -42,69 +50,150 @@ fun ReaderScreen( onBack: () -> Unit, viewModel: ReaderViewModel = hiltViewModel(), ) { - val message by viewModel.message.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(state.deleted) { + if (state.deleted) onBack() + } + Scaffold( topBar = { TopAppBar( title = { Text(stringResource(R.string.title_reader)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.action_back), - ) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) + } + }, + actions = { + val message = state.message + if (message != null) { + IconButton(onClick = viewModel::toggleStar) { + Icon( + Icons.Filled.Star, + contentDescription = stringResource(R.string.reader_star), + tint = if (message.isStarred) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + IconButton(onClick = viewModel::delete) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.reader_delete)) + } } }, ) }, ) { padding -> - val msg = message - if (msg == null) { - Box( - modifier = Modifier.fillMaxSize().padding(padding), - contentAlignment = Alignment.Center, - ) { + val message = state.message + when { + state.loading -> Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() } - } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(16.dp), - ) { - Text(msg.subject, style = MaterialTheme.typography.headlineSmall) - Spacer(Modifier.height(16.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Text( - text = msg.sender.trim().firstOrNull()?.uppercase() ?: "?", - color = MaterialTheme.colorScheme.onPrimaryContainer, - style = MaterialTheme.typography.titleMedium, - ) - } - Spacer(Modifier.width(12.dp)) - Column { - Text(msg.sender, style = MaterialTheme.typography.titleMedium) - Text( - msg.senderEmail, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Spacer(Modifier.height(16.dp)) - HorizontalDivider() - Spacer(Modifier.height(16.dp)) - Text(msg.body, style = MaterialTheme.typography.bodyLarge) + + message != null -> MessageBody( + message = message, + loadRemoteImages = state.loadRemoteImages, + onLoadRemoteImages = viewModel::loadRemoteImages, + contentPadding = padding, + ) + + else -> Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text(state.error ?: stringResource(R.string.reader_empty)) } } } } + +@Composable +private fun MessageBody( + message: Message, + loadRemoteImages: Boolean, + onLoadRemoteImages: () -> Unit, + contentPadding: PaddingValues, +) { + Column(Modifier.fillMaxSize().padding(contentPadding)) { + Header(message) + HorizontalDivider() + if (message.isHtml && !loadRemoteImages) { + RemoteImagesBanner(onLoadRemoteImages) + } + when { + message.body.isBlank() -> Text( + stringResource(R.string.reader_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + + message.isHtml -> HtmlBody( + html = message.body, + loadRemoteImages = loadRemoteImages, + modifier = Modifier.fillMaxSize(), + ) + + else -> SelectionContainer(modifier = Modifier.fillMaxSize()) { + Text( + text = message.body, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + ) + } + } + } +} + +@Composable +private fun Header(message: Message) { + Column(Modifier.padding(16.dp)) { + Text(message.subject, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = message.sender.trim().firstOrNull()?.uppercase() ?: "?", + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(Modifier.width(12.dp)) + Column { + Text(message.sender, style = MaterialTheme.typography.titleMedium) + Text( + message.senderEmail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun RemoteImagesBanner(onLoadRemoteImages: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.reader_images_blocked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onLoadRemoteImages) { + Text(stringResource(R.string.reader_show_images)) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt index b09d4db..dcc3545 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt @@ -6,22 +6,56 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.libremail.domain.model.Message import org.libremail.domain.repository.MailRepository import org.libremail.ui.navigation.Routes +data class ReaderUiState( + val loading: Boolean = true, + val message: Message? = null, + val loadRemoteImages: Boolean = false, + val deleted: Boolean = false, + val error: String? = null, +) + @HiltViewModel class ReaderViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: MailRepository, ) : ViewModel() { + private val messageId: String = checkNotNull(savedStateHandle[Routes.READER_ARG_ID]) - val message: StateFlow = flow { - emit(repository.getMessage(messageId)) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + private val _state = MutableStateFlow(ReaderUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { + repository.openMessage(messageId).fold( + onSuccess = { message -> _state.update { it.copy(loading = false, message = message) } }, + onFailure = { e -> _state.update { it.copy(loading = false, error = e.message ?: "Could not load message") } }, + ) + } + } + + fun toggleStar() { + val message = _state.value.message ?: return + val starred = !message.isStarred + _state.update { it.copy(message = message.copy(isStarred = starred)) } + viewModelScope.launch { repository.setStarred(messageId, starred) } + } + + fun loadRemoteImages() = _state.update { it.copy(loadRemoteImages = true) } + + fun delete() { + viewModelScope.launch { + repository.deleteMessage(messageId) + _state.update { it.copy(deleted = true) } + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bcde14f..2e46847 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,11 @@ Inbox Message + Star + Delete + This message has no text content + Remote images are blocked + Show images Compose Settings Add account diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt index b4a8007..6852f29 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -15,11 +15,17 @@ import org.libremail.data.local.entity.MessageEntity class MailRepositoryImplTest { private val messageDao = mockk() + private val repository = MailRepositoryImpl( + messageDao = messageDao, + accountDao = mockk(), + imapClient = mockk(), + connectionFactory = mockk(), + ) @Test fun `observeMessages is empty when the cache is empty`() = runTest { every { messageDao.observeAll() } returns flowOf(emptyList()) - MailRepositoryImpl(messageDao).observeMessages().test { + repository.observeMessages().test { assertTrue(awaitItem().isEmpty()) awaitComplete() } @@ -40,7 +46,7 @@ class MailRepositoryImplTest { isStarred = false, ) every { messageDao.observeAll() } returns flowOf(listOf(entity)) - MailRepositoryImpl(messageDao).observeMessages().test { + repository.observeMessages().test { val items = awaitItem() assertEquals(1, items.size) assertEquals("Ada", items.first().sender) diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt index 0d6bda1..c679a6a 100644 --- a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt @@ -63,4 +63,16 @@ class ImapClientTest { assertEquals(setOf("First subject", "Second subject"), messages.map { it.subject }.toSet()) assertEquals("bob@example.org", messages.first { it.subject == "First subject" }.senderEmail) } + + @Test + fun `fetchBodyMarkingSeen returns the body and marks the message read`() = runTest { + GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Hello", "The quick brown fox.") + greenMail.waitForIncomingEmail(1) + val uid = client.fetchRecentInbox(params(), limit = 50).first().uid + + val content = client.fetchBodyMarkingSeen(params(), uid) + + assertTrue(content.body.contains("quick brown fox"), "body=${content.body}") + assertTrue(client.fetchRecentInbox(params(), limit = 50).first().isRead, "should be marked read") + } }