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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user