fix(mail): prefer special-use folder when resolving move-by-role destination #92

Merged
JMR-dev merged 2 commits from fix-move-by-role-specialuse into main 2026-07-02 03:20:47 +00:00
2 changed files with 76 additions and 4 deletions
@@ -256,9 +256,16 @@ class MailRepositoryImpl @Inject constructor(
}
}
/** Resolves the full name of an account's folder for [role], refreshing the cache once if needed. */
/**
* Resolves the full name of an account's folder for [role], refreshing the cache once if needed.
* Among same-role selectable folders (e.g. `[Gmail]/Spam` via RFC 6154 `\Junk` plus a user label
* "Spam" matched by name), the server-advertised special-use folder wins regardless of LIST order,
* so mail reaches the provider's built-in mailbox; absent one, the earliest LISTed folder is kept
* (`maxByOrNull` returns the first max).
*/
private suspend fun resolveRoleFolder(accountId: String, role: FolderRole): String? {
fun pick(folders: List<FolderEntity>) = folders.firstOrNull { it.role == role.name && it.selectable }?.fullName
fun pick(folders: List<FolderEntity>) =
folders.filter { it.role == role.name && it.selectable }.maxByOrNull { it.specialUse }?.fullName
pick(folderDao.getForAccountOnce(accountId))?.let { return it }
// The folder cache can be cold (the user may not have opened the drawer yet); refresh and retry.
runCatching { refreshFolders(accountId) }
@@ -399,8 +399,73 @@ class MailRepositoryImplTest {
coVerify { imapClient.moveMessages(any(), "INBOX", listOf("14"), "Archive") }
}
private fun folderEntity(fullName: String, role: String) =
FolderEntity("acct", fullName, fullName.substringAfterLast('/'), role, selectable = true, sortOrder = 0)
@Test
fun `reportSpam prefers the special-use spam folder when the user folder is listed first`() = runTest {
val id = "acct:INBOX:16"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.deleteByIds(any()) } just Runs
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
// A user label "Spam" (role from its name) is LISTed before Gmail's built-in \Junk folder.
coEvery { folderDao.getForAccountOnce("acct") } returns listOf(
folderEntity("Spam", "SPAM"),
folderEntity("[Gmail]/Spam", "SPAM", specialUse = true),
)
val result = repository.reportSpam(listOf(id))
assertTrue(result.isSuccess)
coVerify { imapClient.moveMessages(any(), "INBOX", listOf("16"), "[Gmail]/Spam") }
coVerify(exactly = 0) { imapClient.moveMessages(any(), any(), any(), "Spam") }
}
@Test
fun `reportSpam prefers the special-use spam folder when it is listed first`() = runTest {
val id = "acct:INBOX:18"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.deleteByIds(any()) } just Runs
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
coEvery { folderDao.getForAccountOnce("acct") } returns listOf(
folderEntity("[Gmail]/Spam", "SPAM", specialUse = true),
folderEntity("Spam", "SPAM"),
)
val result = repository.reportSpam(listOf(id))
assertTrue(result.isSuccess)
coVerify { imapClient.moveMessages(any(), "INBOX", listOf("18"), "[Gmail]/Spam") }
coVerify(exactly = 0) { imapClient.moveMessages(any(), any(), any(), "Spam") }
}
@Test
fun `reportSpam keeps the first listed folder when no special-use folder holds the role`() = runTest {
val id = "acct:INBOX:20"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { messageDao.deleteByIds(any()) } just Runs
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
// No SPECIAL-USE advertised (common outside the big providers): LIST order still decides.
coEvery { folderDao.getForAccountOnce("acct") } returns listOf(
folderEntity("Junk", "SPAM"),
folderEntity("Spam", "SPAM"),
)
val result = repository.reportSpam(listOf(id))
assertTrue(result.isSuccess)
coVerify { imapClient.moveMessages(any(), "INBOX", listOf("20"), "Junk") }
}
private fun folderEntity(fullName: String, role: String, specialUse: Boolean = false) = FolderEntity(
accountId = "acct",
fullName = fullName,
displayName = fullName.substringAfterLast('/'),
role = role,
selectable = true,
sortOrder = 0,
specialUse = specialUse,
)
private fun attachmentEntity(messageId: String, partIndex: Int, filename: String) =
AttachmentEntity(messageId, partIndex, filename, "application/octet-stream", 10L)