Add an outbox: reliable, WorkManager-backed send

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 15:46:49 -05:00
co-authored by Claude Opus 4.8
parent fcd84fb933
commit 85ba9d9846
10 changed files with 195 additions and 9 deletions
+2 -1
View File
@@ -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
@@ -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
}
@@ -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`))",
)
}
}
@@ -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<OutboxEntity>
@Query("SELECT COUNT(*) FROM outbox")
fun observeCount(): Flow<Int>
@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)
}
@@ -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,
)
@@ -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<List<Message>> =
@@ -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<Unit> = 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. */
@@ -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<SendWorker>()
.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"
}
}
@@ -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()
}
}
@@ -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()
}
@@ -20,9 +20,10 @@ class MailRepositoryImplTest {
messageDao = messageDao,
accountDao = mockk(),
attachmentDao = mockk(),
outboxDao = mockk(),
imapClient = mockk(),
smtpSender = mockk(),
connectionFactory = mockk(),
sendScheduler = mockk(),
)
@Test