From 85ba9d984642ec9ad5bb5c69947be70fa6899704 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 15:46:49 -0500 Subject: [PATCH] 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