diff --git a/app/src/test/kotlin/org/libremail/data/local/MappersTest.kt b/app/src/test/kotlin/org/libremail/data/local/MappersTest.kt
new file mode 100644
index 0000000..e3d5f1d
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/data/local/MappersTest.kt
@@ -0,0 +1,313 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data.local
+
+import org.junit.Test
+import org.libremail.data.local.entity.AccountEntity
+import org.libremail.data.local.entity.AccountSettingsEntity
+import org.libremail.data.local.entity.AttachmentEntity
+import org.libremail.data.local.entity.FolderUnreadCount
+import org.libremail.data.local.entity.MessageEntity
+import org.libremail.data.local.entity.MessageSummary
+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.AccountSettings
+import org.libremail.domain.model.AuthType
+import org.libremail.domain.model.MailSecurity
+import org.libremail.domain.model.ServerConfig
+import org.libremail.mail.AttachmentPart
+import org.libremail.mail.FetchedMessage
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Round-trips the entity<->domain mappers in `Mappers.kt` that the higher-level repository tests don't
+ * already exercise field-by-field: the account/settings/message/attachment/outbox mappers plus the
+ * connection-param builders. Pins the persisted-enum fallbacks (an unknown `authType`/`security` name
+ * must degrade to a safe default rather than throw) and the composite-id / uid-parse rules that
+ * [FetchedMessage.toEntity] encodes.
+ */
+class MappersTest {
+
+ @Test
+ fun `Account round-trips through the entity, preserving auth type and both server configs`() {
+ val account = Account(
+ id = "outlook:me@outlook.com",
+ email = "me@outlook.com",
+ displayName = "Me",
+ authType = AuthType.OAUTH_OUTLOOK,
+ imap = ServerConfig("outlook.office365.com", 993, MailSecurity.SSL_TLS),
+ smtp = ServerConfig("smtp.office365.com", 587, MailSecurity.STARTTLS),
+ )
+
+ assertEquals(account, account.toEntity().toDomain())
+ }
+
+ @Test
+ fun `AccountEntity toDomain falls back to safe defaults for unknown persisted enum names`() {
+ val entity = AccountEntity(
+ id = "acct",
+ email = "ada@example.org",
+ displayName = "Ada",
+ authType = "SOME_FUTURE_AUTH",
+ imap = ServerConfigEmbedded("imap.example.org", 993, "MYSTERY_SECURITY"),
+ smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
+ )
+
+ val account = entity.toDomain()
+
+ // An unrecognized authType degrades to PASSWORD_IMAP; an unrecognized security to SSL_TLS.
+ assertEquals(AuthType.PASSWORD_IMAP, account.authType)
+ assertEquals(MailSecurity.SSL_TLS, account.imap.security)
+ assertEquals(MailSecurity.SSL_TLS, account.smtp.security)
+ }
+
+ @Test
+ fun `Account toImapParams carries the imap config and auth options with strict STARTTLS by default`() {
+ val params = account().toImapParams(secret = "s3cret", useXoauth2 = true)
+
+ assertEquals("imap.example.org", params.host)
+ assertEquals(993, params.port)
+ assertEquals(MailSecurity.SSL_TLS, params.security)
+ assertEquals("ada@example.org", params.username)
+ assertEquals("s3cret", params.secret)
+ assertTrue(params.useXoauth2)
+ assertTrue(params.strictStartTls)
+ }
+
+ @Test
+ fun `Account toImapParams honors an explicit non-strict STARTTLS flag`() {
+ assertFalse(account().toImapParams(secret = "s", useXoauth2 = false, strictStartTls = false).strictStartTls)
+ }
+
+ @Test
+ fun `Account toSmtpParams carries the smtp config and auth options with strict STARTTLS by default`() {
+ val params = account().toSmtpParams(secret = "token", useXoauth2 = true)
+
+ assertEquals("smtp.example.org", params.host)
+ assertEquals(587, params.port)
+ assertEquals(MailSecurity.STARTTLS, params.security)
+ assertEquals("ada@example.org", params.username)
+ assertEquals("token", params.secret)
+ assertTrue(params.useXoauth2)
+ assertTrue(params.strictStartTls)
+ }
+
+ @Test
+ fun `Account toSmtpParams honors an explicit non-strict STARTTLS flag`() {
+ assertFalse(account().toSmtpParams(secret = "s", useXoauth2 = false, strictStartTls = false).strictStartTls)
+ }
+
+ @Test
+ fun `AccountSettings round-trips through the entity including retention overrides`() {
+ val settings = AccountSettings(
+ accountId = "acct",
+ signature = "Regards, Ada",
+ signatureEnabled = false,
+ notificationsEnabled = false,
+ retentionCount = 500,
+ retentionMonths = 12,
+ )
+
+ assertEquals(settings, settings.toEntity().toDomain())
+ }
+
+ @Test
+ fun `AccountSettingsEntity toDomain keeps null retention overrides and default toggles`() {
+ val domain = AccountSettingsEntity("acct").toDomain()
+
+ assertEquals("acct", domain.accountId)
+ assertEquals("", domain.signature)
+ assertTrue(domain.signatureEnabled)
+ assertTrue(domain.notificationsEnabled)
+ assertNull(domain.retentionCount)
+ assertNull(domain.retentionMonths)
+ }
+
+ @Test
+ fun `MessageEntity toDomain carries every field including the full body`() {
+ val entity = MessageEntity(
+ id = "acct:INBOX:1",
+ accountId = "acct",
+ sender = "Ada",
+ senderEmail = "ada@example.org",
+ subject = "Hi",
+ snippet = "snip",
+ body = "Full body",
+ isHtml = true,
+ timestampMillis = 5L,
+ isRead = true,
+ isStarred = true,
+ folder = "INBOX",
+ inInbox = true,
+ bodyFetched = true,
+ uid = 1L,
+ )
+
+ val message = entity.toDomain()
+
+ assertEquals("acct:INBOX:1", message.id)
+ assertEquals("Full body", message.body)
+ assertTrue(message.isHtml)
+ assertTrue(message.isRead)
+ assertTrue(message.isStarred)
+ assertTrue(message.bodyFetched)
+ assertEquals("snip", message.snippet)
+ assertEquals(5L, message.timestampMillis)
+ }
+
+ @Test
+ fun `MessageSummary toDomain leaves the body empty because the list never renders it`() {
+ val summary = MessageSummary(
+ id = "acct:INBOX:2",
+ accountId = "acct",
+ sender = "Ada",
+ senderEmail = "ada@example.org",
+ subject = "Hi",
+ snippet = "snip",
+ timestampMillis = 5L,
+ isRead = false,
+ isStarred = false,
+ folder = "INBOX",
+ inInbox = true,
+ bodyFetched = false,
+ )
+
+ val message = summary.toDomain()
+
+ assertEquals("", message.body)
+ assertFalse(message.isHtml)
+ assertEquals("acct:INBOX:2", message.id)
+ assertEquals("snip", message.snippet)
+ }
+
+ @Test
+ fun `FetchedMessage toEntity builds a composite id and defaults the cached-body fields`() {
+ val fetched = FetchedMessage(
+ uid = "42",
+ sender = "Ada",
+ senderEmail = "ada@example.org",
+ subject = "Hi",
+ timestampMillis = 1_000L,
+ isRead = true,
+ isFlagged = true,
+ )
+
+ val entity = fetched.toEntity(accountId = "acct", folder = "INBOX")
+
+ assertEquals("acct:INBOX:42", entity.id)
+ assertEquals(42L, entity.uid)
+ assertEquals("acct", entity.accountId)
+ assertEquals("INBOX", entity.folder)
+ assertTrue(entity.isRead)
+ // The server \Flagged flag maps onto the local starred column.
+ assertTrue(entity.isStarred)
+ // A freshly-listed header defaults to in-inbox with no cached body yet.
+ assertTrue(entity.inInbox)
+ assertFalse(entity.bodyFetched)
+ assertEquals("", entity.snippet)
+ assertEquals("", entity.body)
+ assertFalse(entity.isHtml)
+ }
+
+ @Test
+ fun `FetchedMessage toEntity marks a search hit not-in-inbox and zeroes an unparseable uid`() {
+ val fetched = FetchedMessage(
+ uid = "not-a-number",
+ sender = "Ada",
+ senderEmail = "ada@example.org",
+ subject = "Hi",
+ timestampMillis = 0L,
+ isRead = false,
+ isFlagged = false,
+ )
+
+ val entity = fetched.toEntity(accountId = "acct", folder = "INBOX", inInbox = false)
+
+ assertEquals("acct:INBOX:not-a-number", entity.id)
+ // A non-numeric uid can't be materialized, so it stores 0 (refreshed on the next sync).
+ assertEquals(0L, entity.uid)
+ assertFalse(entity.inInbox)
+ }
+
+ @Test
+ fun `FolderUnreadCount toDomain maps the aggregate row to the domain unread count`() {
+ val domain = FolderUnreadCount(accountId = "acct", folder = "INBOX", unreadCount = 7).toDomain()
+
+ assertEquals("acct", domain.accountId)
+ assertEquals("INBOX", domain.folder)
+ assertEquals(7, domain.count)
+ }
+
+ @Test
+ fun `AttachmentEntity toDomain carries the content id for an inline part`() {
+ val domain = AttachmentEntity("m", 2, "logo.png", "image/png", 42L, contentId = "logo@cid").toDomain()
+
+ assertEquals("m", domain.messageId)
+ assertEquals(2, domain.partIndex)
+ assertEquals("logo.png", domain.filename)
+ assertEquals("image/png", domain.mimeType)
+ assertEquals(42L, domain.sizeBytes)
+ assertEquals("logo@cid", domain.contentId)
+ }
+
+ @Test
+ fun `AttachmentEntity toDomain leaves an ordinary attachment content id null`() {
+ assertNull(AttachmentEntity("m", 0, "a.pdf", "application/pdf", 1L).toDomain().contentId)
+ }
+
+ @Test
+ fun `AttachmentPart toEntity attaches the part metadata to its message`() {
+ val entity = AttachmentPart(3, "cat.png", "image/png", 9L, contentId = "cat@cid").toEntity("msg-1")
+
+ assertEquals("msg-1", entity.messageId)
+ assertEquals(3, entity.partIndex)
+ assertEquals("cat.png", entity.filename)
+ assertEquals("image/png", entity.mimeType)
+ assertEquals(9L, entity.sizeBytes)
+ assertEquals("cat@cid", entity.contentId)
+ }
+
+ @Test
+ fun `OutboxEntity toDomain exposes the queue fields, last error, and html body`() {
+ val entity = OutboxEntity(
+ id = "o1",
+ accountId = "acct",
+ toAddresses = "bob@example.org",
+ ccAddresses = "",
+ subject = "Hi",
+ body = "hello",
+ createdAt = 3L,
+ lastError = "smtp 550",
+ bodyHtml = "
hello
",
+ )
+
+ val domain = entity.toDomain()
+
+ assertEquals("o1", domain.id)
+ assertEquals("bob@example.org", domain.to)
+ assertEquals("Hi", domain.subject)
+ assertEquals("hello", domain.body)
+ assertEquals(3L, domain.createdAt)
+ assertEquals("smtp 550", domain.lastError)
+ assertEquals("hello
", domain.bodyHtml)
+ }
+
+ @Test
+ fun `malformed or blank attachment json decodes to an empty list`() {
+ // Blank short-circuits; unparseable JSON is caught and defaulted rather than thrown.
+ assertEquals(emptyList(), " ".toOutgoingAttachments())
+ assertEquals(emptyList(), "not valid json".toOutgoingAttachments())
+ }
+
+ private fun account() = Account(
+ id = "imap:ada@example.org",
+ email = "ada@example.org",
+ displayName = "Ada",
+ authType = AuthType.PASSWORD_IMAP,
+ imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS),
+ smtp = ServerConfig("smtp.example.org", 587, MailSecurity.STARTTLS),
+ )
+}
diff --git a/app/src/test/kotlin/org/libremail/data/repository/AccountRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/AccountRepositoryImplTest.kt
new file mode 100644
index 0000000..96b1b20
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/data/repository/AccountRepositoryImplTest.kt
@@ -0,0 +1,229 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data.repository
+
+import app.cash.turbine.test
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.libremail.data.local.dao.AccountDao
+import org.libremail.data.local.dao.BackfillProgressDao
+import org.libremail.data.local.dao.FolderDao
+import org.libremail.data.local.dao.MessageDao
+import org.libremail.data.local.entity.AccountEntity
+import org.libremail.data.local.entity.ServerConfigEmbedded
+import org.libremail.data.security.CredentialStore
+import org.libremail.data.settings.AccountSettingsRepository
+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.mail.FetchedFolder
+import org.libremail.mail.ImapClient
+import org.libremail.notifications.MailNotifier
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * Covers [AccountRepositoryImpl]'s add/remove/observe surface: the happy paths persist the account,
+ * its credential, its notification channel and kick sync + backfill; the failure paths (a rejected
+ * initial folder LIST) must persist nothing. Every collaborator is mocked, so the assertions pin the
+ * repository's own orchestration, not the DAOs or the IMAP client.
+ */
+class AccountRepositoryImplTest {
+
+ private val accountDao = mockk()
+ private val messageDao = mockk(relaxed = true)
+ private val folderDao = mockk(relaxed = true)
+ private val backfillProgressDao = mockk(relaxed = true)
+ private val credentialStore = mockk(relaxed = true)
+ private val imapClient = mockk()
+ private val syncScheduler = mockk(relaxed = true)
+ private val accountSettingsRepository = mockk(relaxed = true)
+ private val mailNotifier = mockk(relaxed = true)
+
+ private val repository = AccountRepositoryImpl(
+ accountDao = accountDao,
+ messageDao = messageDao,
+ folderDao = folderDao,
+ backfillProgressDao = backfillProgressDao,
+ credentialStore = credentialStore,
+ imapClient = imapClient,
+ syncScheduler = syncScheduler,
+ accountSettingsRepository = accountSettingsRepository,
+ mailNotifier = mailNotifier,
+ )
+
+ @Test
+ fun `observeAccounts maps the stored account rows to domain models`() = runTest {
+ every { accountDao.observeAll() } returns flowOf(listOf(accountEntity()))
+
+ repository.observeAccounts().test {
+ val accounts = awaitItem()
+ assertEquals(1, accounts.size)
+ assertEquals("acct", accounts.first().id)
+ assertEquals("ada@example.org", accounts.first().email)
+ assertEquals(AuthType.PASSWORD_IMAP, accounts.first().authType)
+ assertEquals(MailSecurity.SSL_TLS, accounts.first().imap.security)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `testConnection returns the server's folder names on success`() = runTest {
+ coEvery { imapClient.listFolders(params) } returns listOf(
+ FetchedFolder("INBOX", "INBOX", emptyList(), selectable = true),
+ FetchedFolder("[Gmail]/Sent Mail", "Sent Mail", emptyList(), selectable = true),
+ )
+
+ val result = repository.testConnection(params)
+
+ assertEquals(listOf("INBOX", "[Gmail]/Sent Mail"), result.getOrThrow())
+ }
+
+ @Test
+ fun `testConnection wraps a connection failure in a failed result`() = runTest {
+ coEvery { imapClient.listFolders(params) } throws RuntimeException("no route to host")
+
+ assertTrue(repository.testConnection(params).isFailure)
+ }
+
+ @Test
+ fun `addImapAccount lists folders, persists the account, and starts syncing`() = runTest {
+ val account = account()
+ val used = slot()
+ coEvery { imapClient.listFolders(capture(used)) } returns listOf(
+ FetchedFolder("INBOX", "INBOX", emptyList(), selectable = true),
+ )
+ coEvery { accountDao.upsert(any()) } just Runs
+
+ val result = repository.addImapAccount(account, "app-password")
+
+ assertEquals(listOf("INBOX"), result.getOrThrow())
+ // A password account authenticates the LIST with the app password, not XOAUTH2.
+ assertFalse(used.captured.useXoauth2)
+ assertEquals("app-password", used.captured.secret)
+ assertEquals("ada@example.org", used.captured.username)
+ coVerify { accountDao.upsert(any()) }
+ coVerify { accountSettingsRepository.ensureDefaults(account.id) }
+ coVerify { credentialStore.saveSecret(account.id, "app-password") }
+ verify { mailNotifier.ensureAccountChannel(account) }
+ verify { syncScheduler.syncNow() }
+ verify { syncScheduler.backfillNow() }
+ }
+
+ @Test
+ fun `addImapAccount persists nothing when the initial folder list fails`() = runTest {
+ coEvery { imapClient.listFolders(any()) } throws RuntimeException("bad credentials")
+
+ val result = repository.addImapAccount(account(), "wrong")
+
+ assertTrue(result.isFailure)
+ coVerify(exactly = 0) { accountDao.upsert(any()) }
+ coVerify(exactly = 0) { credentialStore.saveSecret(any(), any()) }
+ verify(exactly = 0) { syncScheduler.syncNow() }
+ }
+
+ @Test
+ fun `addOutlookAccount builds an Outlook account and stores its durable auth state via XOAUTH2`() = runTest {
+ val used = slot()
+ coEvery { imapClient.listFolders(capture(used)) } returns listOf(
+ FetchedFolder("INBOX", "INBOX", emptyList(), selectable = true),
+ )
+ val saved = slot()
+ coEvery { accountDao.upsert(capture(saved)) } just Runs
+
+ val result = repository.addOutlookAccount("me@outlook.com", "access-token", "{authstate}")
+
+ assertEquals(listOf("INBOX"), result.getOrThrow())
+ // The short-lived access token authenticates the initial LIST over XOAUTH2...
+ assertTrue(used.captured.useXoauth2)
+ assertEquals("access-token", used.captured.secret)
+ // ...but the persisted secret is the durable AuthState JSON the refresh flow later reads.
+ coVerify { credentialStore.saveSecret("outlook:me@outlook.com", "{authstate}") }
+ assertEquals("outlook:me@outlook.com", saved.captured.id)
+ assertEquals(AuthType.OAUTH_OUTLOOK.name, saved.captured.authType)
+ verify { syncScheduler.syncNow() }
+ verify { syncScheduler.backfillNow() }
+ }
+
+ @Test
+ fun `addOutlookAccount fails and persists nothing when the token is rejected`() = runTest {
+ coEvery { imapClient.listFolders(any()) } throws RuntimeException("401 unauthorized")
+
+ val result = repository.addOutlookAccount("me@outlook.com", "expired", "{authstate}")
+
+ assertTrue(result.isFailure)
+ coVerify(exactly = 0) { accountDao.upsert(any()) }
+ coVerify(exactly = 0) { credentialStore.saveSecret(any(), any()) }
+ }
+
+ @Test
+ fun `deleteAccount removes the account, its credential, channel, and all cached data`() = runTest {
+ coEvery { accountDao.deleteById("acct") } just Runs
+
+ repository.deleteAccount("acct")
+
+ coVerify { accountDao.deleteById("acct") }
+ coVerify { credentialStore.delete("acct") }
+ verify { mailNotifier.deleteAccountChannel("acct") }
+ coVerify { messageDao.deleteByAccount("acct") }
+ coVerify { folderDao.deleteForAccount("acct") }
+ coVerify { backfillProgressDao.deleteForAccount("acct") }
+ }
+
+ @Test
+ fun `resetBackfillProgress for one account clears only that account and re-kicks backfill`() = runTest {
+ repository.resetBackfillProgress("acct")
+
+ coVerify { backfillProgressDao.deleteForAccount("acct") }
+ coVerify(exactly = 0) { backfillProgressDao.deleteAll() }
+ verify { syncScheduler.backfillNow() }
+ }
+
+ @Test
+ fun `resetBackfillProgress with no account clears all progress and re-kicks backfill`() = runTest {
+ repository.resetBackfillProgress(null)
+
+ coVerify { backfillProgressDao.deleteAll() }
+ coVerify(exactly = 0) { backfillProgressDao.deleteForAccount(any()) }
+ verify { syncScheduler.backfillNow() }
+ }
+
+ private fun account(id: String = "imap:ada@example.org") = Account(
+ id = id,
+ email = "ada@example.org",
+ displayName = "Ada",
+ authType = AuthType.PASSWORD_IMAP,
+ imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS),
+ smtp = ServerConfig("smtp.example.org", 587, MailSecurity.STARTTLS),
+ )
+
+ private fun accountEntity(id: String = "acct", email: String = "ada@example.org") = AccountEntity(
+ id = id,
+ email = email,
+ displayName = "Ada",
+ authType = "PASSWORD_IMAP",
+ imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
+ smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
+ )
+
+ private val params = ImapConnectionParams(
+ host = "imap.example.org",
+ port = 993,
+ security = MailSecurity.SSL_TLS,
+ username = "ada@example.org",
+ secret = "secret",
+ useXoauth2 = false,
+ )
+}
diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplCoverageTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplCoverageTest.kt
new file mode 100644
index 0000000..c48d796
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplCoverageTest.kt
@@ -0,0 +1,663 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data.repository
+
+import android.content.ContentResolver
+import android.content.Context
+import android.net.Uri
+import app.cash.turbine.test
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkAll
+import io.mockk.verify
+import jakarta.mail.Flags
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Test
+import org.libremail.data.attachment.AttachmentUriGrants
+import org.libremail.data.local.dao.AccountDao
+import org.libremail.data.local.dao.AttachmentDao
+import org.libremail.data.local.dao.DraftDao
+import org.libremail.data.local.dao.FolderDao
+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.DraftEntity
+import org.libremail.data.local.entity.FolderUnreadCount
+import org.libremail.data.local.entity.MessageEntity
+import org.libremail.data.local.entity.MessageRouting
+import org.libremail.data.local.entity.OutboxEntity
+import org.libremail.data.local.entity.ServerConfigEmbedded
+import org.libremail.data.settings.AccountSettingsRepository
+import org.libremail.data.settings.SignatureRepository
+import org.libremail.data.sync.MailConnectionFactory
+import org.libremail.data.sync.SendScheduler
+import org.libremail.domain.model.Account
+import org.libremail.domain.model.AccountSettings
+import org.libremail.domain.model.Draft
+import org.libremail.domain.model.ImapConnectionParams
+import org.libremail.domain.model.MailSecurity
+import org.libremail.domain.model.OutgoingAttachment
+import org.libremail.domain.model.OutgoingMessage
+import org.libremail.domain.model.ReplyMode
+import org.libremail.mail.DownloadedAttachment
+import org.libremail.mail.FetchedMessage
+import org.libremail.mail.ImapClient
+import org.libremail.mail.ReplyContext
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Extends [MailRepositoryImplTest]'s coverage to the methods and edge/error branches it doesn't reach:
+ * the observe-* flows, single-message reads, flag/delete/send/search paths, and the "account or row
+ * gone mid-flight" fall-throughs. Kept as a separate, purely-additive file so the existing suite (and
+ * the in-flight search PR that touches it) is left untouched.
+ */
+class MailRepositoryImplCoverageTest {
+
+ private val messageDao = mockk(relaxed = true)
+ private val accountDao = mockk(relaxed = true)
+ private val folderDao = mockk(relaxed = true)
+ private val attachmentDao = mockk(relaxed = true)
+ private val draftDao = mockk(relaxed = true)
+ private val outboxDao = mockk(relaxed = true)
+ private val imapClient = mockk(relaxed = true)
+ private val connectionFactory = mockk()
+ private val context = mockk(relaxed = true)
+ private val sendScheduler = mockk(relaxed = true)
+ private val accountSettingsRepository = mockk(relaxed = true)
+ private val signatureRepository = mockk(relaxed = true)
+
+ private val repository = MailRepositoryImpl(
+ context = context,
+ messageDao = messageDao,
+ accountDao = accountDao,
+ attachmentDao = attachmentDao,
+ outboxDao = outboxDao,
+ draftDao = draftDao,
+ folderDao = folderDao,
+ imapClient = imapClient,
+ connectionFactory = connectionFactory,
+ sendScheduler = sendScheduler,
+ accountSettingsRepository = accountSettingsRepository,
+ signatureRepository = signatureRepository,
+ attachmentUriGrants = mockk(relaxed = true),
+ )
+
+ @After
+ fun tearDown() = unmockkAll()
+
+ // --- single-message reads -------------------------------------------------------------------
+
+ @Test
+ fun `getMessage returns the mapped domain message when it is cached`() = runTest {
+ coEvery { messageDao.getById("acct:INBOX:1") } returns messageEntity("acct:INBOX:1", body = "Hi", isHtml = true)
+
+ val message = repository.getMessage("acct:INBOX:1")
+
+ assertEquals("Hi", message?.body)
+ assertTrue(message?.isHtml == true)
+ }
+
+ @Test
+ fun `getMessage returns null when the message is not cached`() = runTest {
+ coEvery { messageDao.getById("missing") } returns null
+
+ assertNull(repository.getMessage("missing"))
+ }
+
+ // --- observe-* flows ------------------------------------------------------------------------
+
+ @Test
+ fun `observeAttachments maps attachment rows to the domain, carrying inline content ids`() = runTest {
+ every { attachmentDao.observeForMessage("m") } returns flowOf(
+ listOf(
+ AttachmentEntity("m", 0, "a.pdf", "application/pdf", 10L),
+ AttachmentEntity("m", 1, "logo.png", "image/png", 4L, contentId = "logo@cid"),
+ ),
+ )
+
+ repository.observeAttachments("m").test {
+ val list = awaitItem()
+ assertEquals(2, list.size)
+ assertNull(list[0].contentId)
+ assertEquals("logo@cid", list[1].contentId)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `observeUnreadCounts maps the aggregate rows to domain unread counts`() = runTest {
+ every { messageDao.observeUnreadCounts() } returns flowOf(
+ listOf(FolderUnreadCount("acct", "INBOX", 3), FolderUnreadCount("acct", "Work", 1)),
+ )
+
+ repository.observeUnreadCounts().test {
+ val counts = awaitItem()
+ assertEquals(listOf(3, 1), counts.map { it.count })
+ assertEquals("INBOX", counts.first().folder)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `observeDrafts maps draft rows to the domain, decoding their attachments`() = runTest {
+ every { draftDao.observeAll() } returns flowOf(
+ listOf(draftEntity("d1", """[{"uri":"content://f","name":"a.txt"}]""")),
+ )
+
+ repository.observeDrafts().test {
+ val drafts = awaitItem()
+ assertEquals("content://f", drafts.single().attachments.single().uri)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `observeOutbox maps outbox rows to the domain`() = runTest {
+ every { outboxDao.observeAll() } returns flowOf(listOf(outboxEntity("o1")))
+
+ repository.observeOutbox().test {
+ val list = awaitItem()
+ assertEquals("o1", list.single().id)
+ assertEquals("bob@example.org", list.single().to)
+ awaitComplete()
+ }
+ }
+
+ // --- drafts ---------------------------------------------------------------------------------
+
+ @Test
+ fun `getDraft returns the mapped draft when present`() = runTest {
+ coEvery { draftDao.getById("d1") } returns draftEntity("d1", "")
+
+ assertEquals("d1", repository.getDraft("d1")?.id)
+ }
+
+ @Test
+ fun `getDraft returns null when the draft is absent`() = runTest {
+ coEvery { draftDao.getById("nope") } returns null
+
+ assertNull(repository.getDraft("nope"))
+ }
+
+ @Test
+ fun `saveDraft upserts the mapped draft entity`() = runTest {
+ val saved = slot()
+ coEvery { draftDao.upsert(capture(saved)) } just Runs
+
+ repository.saveDraft(
+ Draft(
+ id = "d9",
+ accountId = "acct",
+ to = "bob@example.org",
+ cc = "",
+ subject = "Hi",
+ body = "Yo",
+ updatedAt = 7L,
+ ),
+ )
+
+ assertEquals("d9", saved.captured.id)
+ assertEquals("bob@example.org", saved.captured.toAddresses)
+ }
+
+ // --- setStarred / deleteMessage (server-reachable vs. local-only) ---------------------------
+
+ @Test
+ fun `setStarred writes the local flag then pushes the FLAGGED change to the server`() = runTest {
+ val id = "acct:INBOX:5"
+ coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+
+ val result = repository.setStarred(id, true)
+
+ assertTrue(result.isSuccess)
+ coVerify { messageDao.setStarred(id, true) }
+ coVerify { imapClient.setFlag(any(), "INBOX", "5", Flags.Flag.FLAGGED, true) }
+ }
+
+ @Test
+ fun `setStarred still succeeds locally when the message has no cached routing`() = runTest {
+ val id = "acct:INBOX:6"
+ coEvery { messageDao.getRouting(id) } returns null
+
+ val result = repository.setStarred(id, false)
+
+ assertTrue(result.isSuccess)
+ coVerify { messageDao.setStarred(id, false) }
+ coVerify(exactly = 0) { imapClient.setFlag(any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `deleteMessage removes the local row and deletes it on the server`() = runTest {
+ val id = "acct:INBOX:7"
+ coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+
+ val result = repository.deleteMessage(id)
+
+ assertTrue(result.isSuccess)
+ coVerify { messageDao.deleteById(id) }
+ coVerify { imapClient.deleteMessage(any(), "INBOX", "7") }
+ }
+
+ @Test
+ fun `deleteMessage removes the local row even when there is nothing to delete server-side`() = runTest {
+ val id = "acct:INBOX:8"
+ coEvery { messageDao.getRouting(id) } returns null
+
+ val result = repository.deleteMessage(id)
+
+ assertTrue(result.isSuccess)
+ coVerify { messageDao.deleteById(id) }
+ coVerify(exactly = 0) { imapClient.deleteMessage(any(), any(), any()) }
+ }
+
+ // --- sendMessage / copyAttachments ----------------------------------------------------------
+
+ @Test
+ fun `sendMessage queues the outbox row and triggers the send worker`() = runTest {
+ every { context.cacheDir } returns Files.createTempDirectory("outbox").toFile()
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ val saved = slot()
+ coEvery { outboxDao.insert(capture(saved)) } just Runs
+
+ val result = repository.sendMessage(
+ OutgoingMessage(accountId = "acct", to = "bob@example.org", subject = "Hi", body = "Yo"),
+ )
+
+ assertTrue(result.isSuccess)
+ assertEquals("acct", saved.captured.accountId)
+ assertEquals("bob@example.org", saved.captured.toAddresses)
+ assertEquals("", saved.captured.attachments) // no attachments -> empty json
+ verify { sendScheduler.sendNow() }
+ }
+
+ @Test
+ fun `sendMessage fails when the sending account is missing`() = runTest {
+ coEvery { accountDao.getById("gone") } returns null
+
+ val result = repository.sendMessage(
+ OutgoingMessage(accountId = "gone", to = "b@x.org", subject = "s", body = "b"),
+ )
+
+ assertTrue(result.isFailure)
+ coVerify(exactly = 0) { outboxDao.insert(any()) }
+ verify(exactly = 0) { sendScheduler.sendNow() }
+ }
+
+ @Test
+ fun `sendMessage copies each attachment into the outbox staging dir under a sanitized name`() = runTest {
+ val cache = Files.createTempDirectory("outbox").toFile()
+ every { context.cacheDir } returns cache
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ val saved = slot()
+ coEvery { outboxDao.insert(capture(saved)) } just Runs
+ mockkStatic(Uri::class)
+ every { Uri.parse(any()) } returns mockk()
+ val resolver = mockk()
+ every { context.contentResolver } returns resolver
+ every { resolver.openInputStream(any()) } returns ByteArrayInputStream("PDFDATA".toByteArray())
+
+ val result = repository.sendMessage(
+ OutgoingMessage(
+ accountId = "acct",
+ to = "bob@example.org",
+ subject = "Hi",
+ body = "Yo",
+ attachments = listOf(OutgoingAttachment("content://pick/report", "../evil/report.pdf")),
+ ),
+ )
+
+ assertTrue(result.isSuccess)
+ // The staged file keeps only the sanitized leaf name (no path traversal) with the copied bytes.
+ val staged = File(cache, "outbox/${saved.captured.id}/0/report.pdf")
+ assertTrue(staged.exists())
+ assertEquals("PDFDATA", staged.readText())
+ // The persisted json still carries the picked URI for the send worker.
+ assertTrue(saved.captured.attachments.contains("content://pick/report"))
+ }
+
+ @Test
+ fun `sendMessage tolerates an attachment whose bytes cannot be read and still queues the message`() = runTest {
+ val cache = Files.createTempDirectory("outbox").toFile()
+ every { context.cacheDir } returns cache
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ val saved = slot()
+ coEvery { outboxDao.insert(capture(saved)) } just Runs
+ mockkStatic(Uri::class)
+ every { Uri.parse(any()) } returns mockk()
+ val resolver = mockk()
+ every { context.contentResolver } returns resolver
+ // An unreadable URI resolves to no stream; the copy is skipped but the send still proceeds.
+ every { resolver.openInputStream(any()) } returns null
+
+ val result = repository.sendMessage(
+ OutgoingMessage(
+ accountId = "acct",
+ to = "bob@example.org",
+ subject = "Hi",
+ body = "Yo",
+ attachments = listOf(OutgoingAttachment("content://pick/broken", "x.bin")),
+ ),
+ )
+
+ assertTrue(result.isSuccess)
+ assertFalse(File(cache, "outbox/${saved.captured.id}/0/x.bin").exists())
+ verify { sendScheduler.sendNow() }
+ }
+
+ @Test
+ fun `buildReplyDraft omits the signature block when the account has signatures disabled`() = runTest {
+ val id = "acct:INBOX:20"
+ coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+ coEvery { accountSettingsRepository.get("acct") } returns AccountSettings("acct", signatureEnabled = false)
+ coEvery { imapClient.fetchForReply(any(), "INBOX", "20") } returns ReplyContext(
+ fromEmail = "boss@example.org",
+ toRecipients = listOf("ada@example.org"),
+ ccRecipients = emptyList(),
+ subject = "Plan",
+ sentDateMillis = 0L,
+ body = "Original",
+ isHtml = false,
+ )
+ val draft = slot()
+ coEvery { draftDao.upsert(capture(draft)) } just Runs
+
+ val result = repository.buildReplyDraft(id, ReplyMode.REPLY)
+
+ assertTrue(result.isSuccess)
+ // With signatures off, no RFC 3676 "-- " delimiter is baked above the quote, and the default
+ // signature is never even resolved.
+ assertFalse(draft.captured.body.contains("-- "))
+ coVerify(exactly = 0) { signatureRepository.getDefault(any()) }
+ }
+
+ @Test
+ fun `retryOutbox re-triggers the send worker`() = runTest {
+ repository.retryOutbox()
+
+ verify { sendScheduler.sendNow() }
+ }
+
+ // --- server search --------------------------------------------------------------------------
+
+ @Test
+ fun `searchServer queries every account when unfiltered and stores hits as search-only`() = runTest {
+ coEvery { accountDao.getAll() } returns listOf(
+ accountEntity(id = "acct"),
+ accountEntity(id = "acct2", email = "bob@example.org"),
+ )
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+ coEvery { imapClient.search(any(), "INBOX", "invoice", any()) } returns listOf(
+ FetchedMessage("11", "Ada", "ada@example.org", "Invoice", 1L, isRead = false, isFlagged = false),
+ )
+ val inserted = slot>()
+ coEvery { messageDao.insertNew(capture(inserted)) } just Runs
+
+ repository.searchServer("invoice", accountId = null, folder = "INBOX")
+
+ // Both accounts are searched, and every hit is marked search-only (inInbox = false) so it
+ // never masquerades as a synced row.
+ coVerify(exactly = 2) { imapClient.search(any(), "INBOX", "invoice", any()) }
+ assertTrue(inserted.captured.isNotEmpty() && inserted.captured.all { !it.inInbox })
+ coVerify { messageDao.updateHeaderContent(any(), any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `searchServer restricts the search to the requested account`() = runTest {
+ coEvery { accountDao.getAll() } returns listOf(
+ accountEntity(id = "acct"),
+ accountEntity(id = "acct2", email = "bob@example.org"),
+ )
+ val searched = slot()
+ coEvery { connectionFactory.imapParamsFor(capture(searched)) } returns imapParams()
+ coEvery { imapClient.search(any(), "INBOX", "q", any()) } returns emptyList()
+
+ repository.searchServer("q", accountId = "acct2", folder = "INBOX")
+
+ // Only the filtered account is contacted; an empty result inserts an empty batch and updates
+ // no headers.
+ assertEquals("acct2", searched.captured.id)
+ coVerify(exactly = 1) { imapClient.search(any(), "INBOX", "q", any()) }
+ coVerify(exactly = 1) { messageDao.insertNew(emptyList()) }
+ coVerify(exactly = 0) { messageDao.updateHeaderContent(any(), any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `clearSearchResults deletes the transient search rows`() = runTest {
+ repository.clearSearchResults()
+
+ coVerify { messageDao.deleteSearchRows() }
+ }
+
+ // --- inlineImages edge cases ----------------------------------------------------------------
+
+ @Test
+ fun `inlineImages returns nothing when the message has no routing`() = runTest {
+ coEvery { messageDao.getRouting("gone") } returns null
+
+ assertTrue(repository.inlineImages("gone").isEmpty())
+ }
+
+ @Test
+ fun `inlineImages omits a cid part whose bytes cannot be fetched`() = runTest {
+ every { context.cacheDir } returns Files.createTempDirectory("attach").toFile()
+ val id = "acct:INBOX:9"
+ coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
+ coEvery { attachmentDao.getForMessage(id) } returns listOf(
+ AttachmentEntity(id, 0, "logo.png", "image/png", 4L, contentId = "logo@cid"),
+ )
+ // No cached file and the account is gone, so the on-demand fetch throws and the image is dropped.
+ coEvery { accountDao.getById("acct") } returns null
+
+ assertTrue(repository.inlineImages(id).isEmpty())
+ }
+
+ // --- openMessage fall-throughs --------------------------------------------------------------
+
+ @Test
+ fun `openMessage fails when the message id has no routing`() = runTest {
+ coEvery { messageDao.getRouting("gone") } returns null
+
+ assertTrue(repository.openMessage("gone").isFailure)
+ }
+
+ @Test
+ fun `openMessage skips the server fetch when the account has been removed`() = runTest {
+ val id = "acct:INBOX:10"
+ coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX") // unread, no body
+ coEvery { accountDao.getById("acct") } returns null
+ coEvery { messageDao.getById(id) } returns messageEntity(id, body = "stale")
+
+ val result = repository.openMessage(id)
+
+ assertEquals("stale", result.getOrThrow().body)
+ coVerify(exactly = 0) { imapClient.fetchBodyMarkingSeen(any(), any(), any()) }
+ coVerify(exactly = 0) { messageDao.setRead(any(), any()) }
+ }
+
+ @Test
+ fun `openMessage fails when the row vanishes before the body read`() = runTest {
+ val id = "acct:INBOX:11"
+ coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX", bodyFetched = true, isRead = true)
+ coEvery { messageDao.getById(id) } returns null
+
+ assertTrue(repository.openMessage(id).isFailure)
+ }
+
+ @Test
+ fun `refreshFolders fails when the account is unknown`() = runTest {
+ coEvery { accountDao.getById("gone") } returns null
+
+ assertTrue(repository.refreshFolders("gone").isFailure)
+ }
+
+ // --- prefetchMessage / downloadAttachment ---------------------------------------------------
+
+ @Test
+ fun `prefetchMessage is a no-op success when the message has no routing`() = runTest {
+ coEvery { messageDao.getRouting("gone") } returns null
+
+ assertTrue(repository.prefetchMessage("gone").isSuccess)
+ coVerify(exactly = 0) { imapClient.fetchBodyPeek(any(), any(), any()) }
+ }
+
+ @Test
+ fun `prefetchMessage is a no-op success when the account has been removed`() = runTest {
+ val id = "acct:INBOX:12"
+ coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
+ coEvery { accountDao.getById("acct") } returns null
+
+ assertTrue(repository.prefetchMessage(id).isSuccess)
+ coVerify(exactly = 0) { imapClient.fetchBodyPeek(any(), any(), any()) }
+ }
+
+ @Test
+ fun `downloadAttachment falls back to a default filename when the part metadata is missing`() = runTest {
+ val cache = Files.createTempDirectory("attach").toFile()
+ every { context.cacheDir } returns cache
+ val id = "acct:INBOX:13"
+ coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
+ coEvery { attachmentDao.getForMessage(id) } returns emptyList() // no metadata for part 0
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+ coEvery { imapClient.fetchAttachment(any(), "INBOX", "13", 0) } returns
+ DownloadedAttachment("attachment", "application/octet-stream", "bytes".toByteArray())
+
+ val result = repository.downloadAttachment(id, 0)
+
+ assertTrue(result.isSuccess)
+ // Stored under the "attachment" fallback name since no cached metadata carried a filename.
+ assertEquals(File(cache, "attachments/acct_INBOX_13/0/attachment"), result.getOrThrow())
+ }
+
+ // --- batch move fall-throughs ---------------------------------------------------------------
+
+ @Test
+ fun `moveToFolder skips the server move for messages already in the destination folder`() = runTest {
+ val id = "acct:Receipts:14"
+ coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "Receipts"))
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+
+ val result = repository.moveToFolder(listOf(id), "Receipts")
+
+ assertTrue(result.isSuccess)
+ coVerify { messageDao.deleteByIds(listOf(id)) } // still optimistically removed locally
+ coVerify(exactly = 0) { imapClient.moveMessages(any(), any(), any(), any()) } // no self-move
+ }
+
+ @Test
+ fun `expunge skips messages whose account no longer exists`() = runTest {
+ val id = "acct:INBOX:15"
+ coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
+ coEvery { accountDao.getById("acct") } returns null // account removed mid-flight
+
+ val result = repository.expunge(listOf(id))
+
+ assertTrue(result.isSuccess)
+ coVerify { messageDao.deleteByIds(listOf(id)) } // local removal still happens
+ coVerify(exactly = 0) { imapClient.deleteMessage(any(), any(), any()) } // nothing pushed server-side
+ }
+
+ // --- fixtures -------------------------------------------------------------------------------
+
+ private fun messageEntity(
+ id: String,
+ folder: String = "INBOX",
+ accountId: String = "acct",
+ body: String = "",
+ isHtml: Boolean = false,
+ bodyFetched: Boolean = false,
+ isRead: Boolean = false,
+ ) = MessageEntity(
+ id = id,
+ accountId = accountId,
+ sender = "Ada",
+ senderEmail = "ada@example.org",
+ subject = "Hi",
+ snippet = "snippet",
+ body = body,
+ isHtml = isHtml,
+ timestampMillis = 1_000L,
+ isRead = isRead,
+ isStarred = false,
+ folder = folder,
+ bodyFetched = bodyFetched,
+ )
+
+ private fun messageRouting(
+ id: String,
+ folder: String,
+ accountId: String = "acct",
+ bodyFetched: Boolean = false,
+ isRead: Boolean = false,
+ ) = MessageRouting(
+ id = id,
+ accountId = accountId,
+ folder = folder,
+ uid = id.substringAfterLast(':').toLongOrNull() ?: 0L,
+ isRead = isRead,
+ isStarred = false,
+ bodyFetched = bodyFetched,
+ isHtml = false,
+ )
+
+ private fun draftEntity(id: String, attachmentsJson: String) = DraftEntity(
+ id = id,
+ accountId = "acct",
+ toAddresses = "bob@example.org",
+ ccAddresses = "",
+ bccAddresses = "",
+ subject = "Hi",
+ body = "body",
+ updatedAt = 0L,
+ attachments = attachmentsJson,
+ )
+
+ private fun outboxEntity(id: String) = OutboxEntity(
+ id = id,
+ accountId = "acct",
+ toAddresses = "bob@example.org",
+ ccAddresses = "",
+ subject = "Hi",
+ body = "body",
+ createdAt = 0L,
+ )
+
+ private fun accountEntity(id: String = "acct", email: String = "ada@example.org") = AccountEntity(
+ id = id,
+ email = email,
+ displayName = "Ada",
+ authType = "PASSWORD_IMAP",
+ imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
+ smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
+ )
+
+ private fun imapParams() = ImapConnectionParams(
+ host = "imap.example.org",
+ port = 993,
+ security = MailSecurity.SSL_TLS,
+ username = "ada@example.org",
+ secret = "secret",
+ useXoauth2 = false,
+ )
+}
diff --git a/app/src/test/kotlin/org/libremail/domain/model/DomainModelCoverageTest.kt b/app/src/test/kotlin/org/libremail/domain/model/DomainModelCoverageTest.kt
new file mode 100644
index 0000000..ec8a573
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/domain/model/DomainModelCoverageTest.kt
@@ -0,0 +1,164 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.domain.model
+
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Covers the pure behaviour and field-carrying contracts of the domain models that the mapper and
+ * repository suites don't reach directly: [AccountSettings.signatureBlock]'s three branches,
+ * [Signature.plainText], the default-argument constructors ([OutboxMessage]/[Signature]) and the
+ * display-name fallbacks in [Account.outlook] / [MailProvider.createAccount].
+ */
+class DomainModelCoverageTest {
+
+ @Test
+ fun `signatureBlock appends an RFC 3676 delimited, right-trimmed block when enabled and non-blank`() {
+ val settings = AccountSettings(accountId = "acct", signature = "Ada Lovelace ", signatureEnabled = true)
+
+ assertEquals("\n\n-- \nAda Lovelace", settings.signatureBlock())
+ }
+
+ @Test
+ fun `signatureBlock is empty when signatures are disabled`() {
+ assertEquals("", AccountSettings("acct", signature = "Ada", signatureEnabled = false).signatureBlock())
+ }
+
+ @Test
+ fun `signatureBlock is empty when the signature is blank even though it is enabled`() {
+ assertEquals("", AccountSettings("acct", signature = " ", signatureEnabled = true).signatureBlock())
+ }
+
+ @Test
+ fun `AccountSettings defaults enable signatures and notifications with no retention override`() {
+ val settings = AccountSettings("acct")
+
+ assertTrue(settings.signatureEnabled)
+ assertTrue(settings.notificationsEnabled)
+ assertNull(settings.retentionCount)
+ assertNull(settings.retentionMonths)
+ assertEquals("", settings.signature)
+ }
+
+ @Test
+ fun `Signature exposes its fields, defaults to non-default, and converts its html to plain text`() {
+ val sig = Signature(id = "acct:sig", accountId = "acct", name = "Work", html = "Regards, Ada
")
+
+ assertEquals("acct:sig", sig.id)
+ assertEquals("acct", sig.accountId)
+ assertEquals("Work", sig.name)
+ assertEquals("Regards, Ada
", sig.html)
+ assertFalse(sig.isDefault)
+ assertTrue(sig.plainText().contains("Regards"))
+ }
+
+ @Test
+ fun `Signature can be flagged as the account default`() {
+ assertTrue(Signature("i", "acct", "Primary", "x
", isDefault = true).isDefault)
+ }
+
+ @Test
+ fun `OutboxMessage defaults its html body to null`() {
+ val msg =
+ OutboxMessage(id = "o", to = "bob@example.org", subject = "s", body = "b", createdAt = 1L, lastError = null)
+
+ assertEquals("o", msg.id)
+ assertEquals("bob@example.org", msg.to)
+ assertEquals("s", msg.subject)
+ assertEquals("b", msg.body)
+ assertEquals(1L, msg.createdAt)
+ assertNull(msg.lastError)
+ assertNull(msg.bodyHtml)
+ }
+
+ @Test
+ fun `OutboxMessage carries an html body and last error when supplied`() {
+ val msg = OutboxMessage("o", "bob@example.org", "s", "b", 1L, "smtp 550", "b
")
+
+ assertEquals("smtp 550", msg.lastError)
+ assertEquals("b
", msg.bodyHtml)
+ }
+
+ @Test
+ fun `Account outlook falls back to the email when the display name is blank`() {
+ val account = Account.outlook("me@outlook.com", displayName = "")
+
+ assertEquals("me@outlook.com", account.displayName)
+ assertEquals("outlook:me@outlook.com", account.id)
+ assertEquals("me@outlook.com", account.email)
+ assertEquals(AuthType.OAUTH_OUTLOOK, account.authType)
+ assertEquals("outlook.office365.com", account.imap.host)
+ assertEquals("smtp.office365.com", account.smtp.host)
+ }
+
+ @Test
+ fun `Account outlook keeps an explicit non-blank display name`() {
+ assertEquals("Work", Account.outlook("me@outlook.com", "Work").displayName)
+ }
+
+ @Test
+ fun `MailProvider createAccount falls back to the trimmed email when the display name is blank`() {
+ val account = MailProvider.GMAIL.createAccount("user@gmail.com", displayName = " ")
+
+ assertEquals("user@gmail.com", account.displayName)
+ }
+
+ @Test
+ fun `Message carries every field it is constructed with`() {
+ val message = Message(
+ id = "acct:INBOX:1",
+ accountId = "acct",
+ sender = "Ada",
+ senderEmail = "ada@example.org",
+ subject = "Hi",
+ snippet = "snip",
+ body = "body",
+ isHtml = true,
+ timestampMillis = 5L,
+ isRead = true,
+ isStarred = true,
+ folder = "INBOX",
+ inInbox = true,
+ bodyFetched = true,
+ )
+
+ assertEquals("acct:INBOX:1", message.id)
+ assertEquals("acct", message.accountId)
+ assertEquals("Ada", message.sender)
+ assertEquals("ada@example.org", message.senderEmail)
+ assertEquals("Hi", message.subject)
+ assertEquals("snip", message.snippet)
+ assertEquals("body", message.body)
+ assertTrue(message.isHtml)
+ assertEquals(5L, message.timestampMillis)
+ assertTrue(message.isRead)
+ assertTrue(message.isStarred)
+ assertEquals("INBOX", message.folder)
+ assertTrue(message.inInbox)
+ assertTrue(message.bodyFetched)
+ }
+
+ @Test
+ fun `Folder carries every field including the parsed hierarchy delimiter`() {
+ val folder = Folder(
+ accountId = "acct",
+ fullName = "[Gmail]/Sent Mail",
+ displayName = "Sent Mail",
+ role = FolderRole.SENT,
+ selectable = true,
+ specialUse = true,
+ hierarchyDelimiter = '/',
+ )
+
+ assertEquals("acct", folder.accountId)
+ assertEquals("[Gmail]/Sent Mail", folder.fullName)
+ assertEquals("Sent Mail", folder.displayName)
+ assertEquals(FolderRole.SENT, folder.role)
+ assertTrue(folder.selectable)
+ assertTrue(folder.specialUse)
+ assertEquals('/', folder.hierarchyDelimiter)
+ }
+}