Add IMAP receive: background sync of inbox into Room

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 18:25:54 -05:00
co-authored by Claude Opus 4.8
parent 9c74510832
commit 5386ae76d3
24 changed files with 535 additions and 197 deletions
+5 -5
View File
@@ -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)
+5
View File
@@ -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)
+15 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
@@ -23,5 +24,18 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- WorkManager is initialized on-demand via Configuration.Provider, so remove the
default automatic initializer. -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>
@@ -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()
}
}
@@ -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 {
@@ -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,
)
@@ -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)
@@ -13,6 +13,9 @@ interface AccountDao {
@Query("SELECT * FROM accounts ORDER BY email")
fun observeAll(): Flow<List<AccountEntity>>
@Query("SELECT * FROM accounts ORDER BY email")
suspend fun getAll(): List<AccountEntity>
@Query("SELECT * FROM accounts WHERE id = :id LIMIT 1")
suspend fun getById(id: String): AccountEntity?
@@ -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<List<MessageEntity>>
abstract fun observeAll(): Flow<List<MessageEntity>>
@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<MessageEntity>)
abstract suspend fun upsertAll(messages: List<MessageEntity>)
@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<MessageEntity>) {
deleteByAccount(accountId)
upsertAll(messages)
}
}
@@ -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<List<Account>> =
@@ -31,9 +31,10 @@ class AccountRepositoryImpl @Inject constructor(
runCatching { imapClient.listFolders(params) }
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> = 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<List<String>> = 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)
@@ -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<List<Message>> =
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,
)
@@ -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<Message> = 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 }
}
@@ -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<Int> {
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<Int> = 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
}
}
@@ -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<SyncWorker>(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<SyncWorker>()
.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"
}
}
@@ -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() },
)
}
@@ -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<String> = 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<FetchedMessage> =
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 <T> 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() }
}
@@ -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,
)
}
@@ -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,
)
@@ -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<List<Message>> =
repository.observeMessages()
mailRepository.observeMessages()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val hasAccounts: StateFlow<Boolean> =
accountRepository.observeAccounts()
.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _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
}
}
+3 -1
View File
@@ -19,7 +19,9 @@
<!-- Mailbox -->
<string name="mailbox_empty">No messages yet</string>
<string name="mailbox_empty_subtitle">Add an account to start syncing your mail.</string>
<string name="mailbox_pull_to_refresh">Pull down to refresh</string>
<string name="mailbox_welcome_title">Welcome to LibreMail</string>
<string name="mailbox_welcome_subtitle">Add an account to start reading your mail.</string>
<!-- Compose -->
<string name="compose_to">To</string>
@@ -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<MessageDao>()
@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)
@@ -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"))
}
}
@@ -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<Exception> {
client.listFolders(params(secret = "wrong-password"))
}
assertFailsWith<Exception> { 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)
}
}
+5
View File
@@ -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" }