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
JMR-dev commented 2026-07-02 02:49:11 +00:00 (Migrated from github.com)

Problem

resolveRoleFolder().pick() in MailRepositoryImpl chose the destination for archive() / reportSpam() / trash() as the first selectable folder whose role matched, in server LIST order (FolderDao.getForAccountOnce is ORDER BY sortOrder ASC, and sortOrder is the LIST index). A provider's built-in folder and a same-named user folder can hold the same role — [Gmail]/Spam gets SPAM via the RFC 6154 \Junk attribute, while a user label "Spam" gets SPAM via roleFromDisplayName — so the winner was whichever the server happened to LIST first.

Failure scenario: on a Gmail account with a user label "Spam" alongside [Gmail]/Spam, tapping Report spam could move messages into the user label. Mail then never reaches Gmail's junk mailbox — no spam training, no 30-day auto-purge — and since local rows are deleted optimistically and the confirm dialog never names the destination, the misfile is silent. The same order-dependence affected archive ([Gmail]/All Mail vs. user "Archive") and trash (provider retention/purge skipped).

Fix

PR #54 already persists specialUse (RFC 6154, Boolean, column default 0) on the folder rows pick() scans. Prefer it among same-role matches:

folders.filter { it.role == role.name && it.selectable }.maxByOrNull { it.specialUse }?.fullName

Boolean is Comparable (true > false), so a server-advertised special-use folder always beats a name-derived one; and because maxByOrNull returns the first element with the max value, LIST order still decides when no special-use folder exists (or between two special-use folders) — the previous behavior is preserved exactly in those cases. The larger role-derivation refactor stays tracked in #65; this is only the surgical destination fix.

Tests

New MailRepositoryImplTest cases (each verified to fail against the old firstOrNull code where applicable):

  • reportSpam prefers the special-use spam folder when the user folder is listed first — the bug scenario; also asserts no move to the user folder happens
  • reportSpam prefers the special-use spam folder when it is listed first — proves the choice is order-independent
  • reportSpam keeps the first listed folder when no special-use folder holds the role — the fallback keeps LIST order

Fast CI gate run locally: assembleDebug, testDebugUnitTest, lintDebug, ktlintCheck, detekt, plus compileDebugAndroidTestKotlin — all green.

Closes #58

🤖 Generated with Claude Code

## Problem `resolveRoleFolder().pick()` in `MailRepositoryImpl` chose the destination for `archive()` / `reportSpam()` / `trash()` as the **first** selectable folder whose role matched, in server LIST order (`FolderDao.getForAccountOnce` is `ORDER BY sortOrder ASC`, and `sortOrder` is the LIST index). A provider's built-in folder and a same-named user folder can hold the same role — `[Gmail]/Spam` gets SPAM via the RFC 6154 `\Junk` attribute, while a user label "Spam" gets SPAM via `roleFromDisplayName` — so the winner was whichever the server happened to LIST first. **Failure scenario:** on a Gmail account with a user label "Spam" alongside `[Gmail]/Spam`, tapping *Report spam* could move messages into the user label. Mail then never reaches Gmail's junk mailbox — no spam training, no 30-day auto-purge — and since local rows are deleted optimistically and the confirm dialog never names the destination, the misfile is silent. The same order-dependence affected archive (`[Gmail]/All Mail` vs. user "Archive") and trash (provider retention/purge skipped). ## Fix PR #54 already persists `specialUse` (RFC 6154, `Boolean`, column default `0`) on the folder rows `pick()` scans. Prefer it among same-role matches: ```kotlin folders.filter { it.role == role.name && it.selectable }.maxByOrNull { it.specialUse }?.fullName ``` `Boolean` is `Comparable` (`true > false`), so a server-advertised special-use folder always beats a name-derived one; and because `maxByOrNull` returns the **first** element with the max value, LIST order still decides when no special-use folder exists (or between two special-use folders) — the previous behavior is preserved exactly in those cases. The larger role-derivation refactor stays tracked in #65; this is only the surgical destination fix. ## Tests New `MailRepositoryImplTest` cases (each verified to fail against the old `firstOrNull` code where applicable): - `reportSpam prefers the special-use spam folder when the user folder is listed first` — the bug scenario; also asserts no move to the user folder happens - `reportSpam prefers the special-use spam folder when it is listed first` — proves the choice is order-independent - `reportSpam keeps the first listed folder when no special-use folder holds the role` — the fallback keeps LIST order Fast CI gate run locally: `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `ktlintCheck`, `detekt`, plus `compileDebugAndroidTestKotlin` — all green. Closes #58 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.