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