Replaces the fixed 50-message-per-folder header cap with a background,
resumable full-history backfill, and adds a user-configurable device-only
retention limit that prunes local mail beyond it — never deleting from the
server.
⚠ Architecture-critical — please review these surfaces closely
Backfill resumability / cancellation — MailBackfiller.backfillFolder
pages a folder newest→oldest via ImapClient.fetchOlderThan. It derives the
next boundary from the lowest currently-cached UID each batch (not a
stored cursor) and persists progress in the new backfill_progress table
after every NonCancellable batch write. A run stopped by process death /
network loss / WorkManager cancellation resumes from the cached rows. It runs offMailSyncer's sync mutex so pull-to-refresh stays responsive. Please
sanity-check the resume invariant and that headers are always persisted
before the boundary advances.
Backfill ↔ prune precedence — the rule is: backfill pauses (does not
mark the folder complete) once the account's retention floor is reached, and
the pruner only deletes below that same floor — disjoint working sets.
They also share a process-wide MailMaintenanceGate mutex so they never run
at once (defence in depth). Because backfill pauses (not completes) at the
floor, loosening retention later resumes filling automatically. Please review reachedRetentionFloor + the "pause vs complete" distinction.
Server-load batching / backoff — fetchOlderThan bounds both memory
(one BACKFILL_BATCH_SIZE=50 page materialized) and network (boundary found
by binary search over message numbers = O(log n) tiny UID FETCHs, not a
full lower-range scan). Each WorkManager run does ≤DEFAULT_MAX_BATCHES=20
pages with a 250 ms inter-page delay; periodic cadence is 30 min, battery-
not-low + network constrained. Is the batch/backoff shape acceptable for
large mailboxes? Note: body prefetch during backfill honours FetchPolicy
(default ALWAYS) and can be heavy — flagging for a tuning opinion.
Room schema migration (9 → 10) — MIGRATION_9_10 adds messages.uid (backfilled from the id's numeric tail via rtrim(id,'0123456789')), nullable account_settings.retentionCount/Months,
and the backfill_progress table. Exported 10.json is committed and a MigrationTestHelper test (Migration9To10Test, instrumented) validates it.
What changed
#12:messages.uid materialized column powers a windowed deletion
reconcile in MailSyncer (deleteSyncedInWindowNotIn) so foreground sync no
longer wipes everything outside the recent 50 — backfilled history survives.
Backfill kicked on account-add and on a periodic schedule.
#13: per-account count/age retention overrides + global default
(RetentionPolicy, 0 = keep everything). MailPruner deletes local rows
beyond the limit (attachment rows cascade; on-disk cache removed; deletes
chunked under SQLite's 999-param limit) and never issues a server delete.
Settings UI for global + per-account with "device only, not the server" copy.
Self-review found & fixed
Foreground↔prune re-download fight when a count limit is below the 50-msg
window → foreground fetch window is now capped by the retention count.
deleteByIds could exceed SQLite's 999-parameter limit on a first large prune
→ chunked.
Two instrumented ViewModel tests broken by the new SyncScheduler DI param →
updated.
Testing
Fast CI gate green: assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt. New JVM tests use GreenMail to prove the paged
backfill caches >50 messages and resumes after an interruption
(ImapClientBackfillTest, MailBackfillerTest), plus count/age pruning that
never asks the server to delete (MailPrunerTest) and RetentionPolicyTest.
Left to CI / manual (needs an emulator): the MigrationTestHelper migration
test and any Compose UI verification of the new retention settings + a backfill
progress indicator (no progress UI is included in this PR).
Closes #12. Closes #13.
Replaces the fixed 50-message-per-folder header cap with a background,
resumable **full-history backfill**, and adds a user-configurable **device-only
retention** limit that prunes local mail beyond it — never deleting from the
server.
## ⚠ Architecture-critical — please review these surfaces closely
1. **Backfill resumability / cancellation** — `MailBackfiller.backfillFolder`
pages a folder newest→oldest via `ImapClient.fetchOlderThan`. It derives the
next boundary from the **lowest currently-cached UID** each batch (not a
stored cursor) and persists progress in the new `backfill_progress` table
after every `NonCancellable` batch write. A run stopped by process death /
network loss / WorkManager cancellation resumes from the cached rows. It runs
**off** `MailSyncer`'s sync mutex so pull-to-refresh stays responsive. Please
sanity-check the resume invariant and that headers are always persisted
before the boundary advances.
2. **Backfill ↔ prune precedence** — the rule is: backfill **pauses** (does not
mark the folder complete) once the account's retention floor is reached, and
the pruner only deletes **below** that same floor — disjoint working sets.
They also share a process-wide `MailMaintenanceGate` mutex so they never run
at once (defence in depth). Because backfill pauses (not completes) at the
floor, loosening retention later resumes filling automatically. Please review
`reachedRetentionFloor` + the "pause vs complete" distinction.
3. **Server-load batching / backoff** — `fetchOlderThan` bounds both memory
(one `BACKFILL_BATCH_SIZE`=50 page materialized) and network (boundary found
by binary search over message numbers = O(log n) tiny `UID FETCH`s, not a
full lower-range scan). Each WorkManager run does ≤`DEFAULT_MAX_BATCHES`=20
pages with a 250 ms inter-page delay; periodic cadence is 30 min, battery-
not-low + network constrained. Is the batch/backoff shape acceptable for
large mailboxes? Note: body prefetch during backfill honours `FetchPolicy`
(default `ALWAYS`) and can be heavy — flagging for a tuning opinion.
4. **Room schema migration (9 → 10)** — `MIGRATION_9_10` adds
`messages.uid` (backfilled from the id's numeric tail via
`rtrim(id,'0123456789')`), nullable `account_settings.retentionCount/Months`,
and the `backfill_progress` table. Exported `10.json` is committed and a
`MigrationTestHelper` test (`Migration9To10Test`, instrumented) validates it.
## What changed
- **#12:** `messages.uid` materialized column powers a **windowed** deletion
reconcile in `MailSyncer` (`deleteSyncedInWindowNotIn`) so foreground sync no
longer wipes everything outside the recent 50 — backfilled history survives.
Backfill kicked on account-add and on a periodic schedule.
- **#13:** per-account count/age retention overrides + global default
(`RetentionPolicy`, 0 = keep everything). `MailPruner` deletes local rows
beyond the limit (attachment rows cascade; on-disk cache removed; deletes
chunked under SQLite's 999-param limit) and **never** issues a server delete.
Settings UI for global + per-account with "device only, not the server" copy.
## Self-review found & fixed
- Foreground↔prune re-download fight when a count limit is **below** the 50-msg
window → foreground fetch window is now capped by the retention count.
- `deleteByIds` could exceed SQLite's 999-parameter limit on a first large prune
→ chunked.
- Two instrumented ViewModel tests broken by the new `SyncScheduler` DI param →
updated.
## Testing
Fast CI gate green: `assembleDebug` + `testDebugUnitTest` + `lintDebug` +
`ktlintCheck` + `detekt`. New JVM tests use **GreenMail** to prove the paged
backfill caches **>50** messages and **resumes** after an interruption
(`ImapClientBackfillTest`, `MailBackfillerTest`), plus count/age pruning that
never asks the server to delete (`MailPrunerTest`) and `RetentionPolicyTest`.
**Left to CI / manual (needs an emulator):** the `MigrationTestHelper` migration
test and any Compose UI verification of the new retention settings + a backfill
progress indicator (no progress UI is included in this PR).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #12. Closes #13.
Replaces the fixed 50-message-per-folder header cap with a background,
resumable full-history backfill, and adds a user-configurable device-only
retention limit that prunes local mail beyond it — never deleting from the
server.
⚠ Architecture-critical — please review these surfaces closely
Backfill resumability / cancellation —
MailBackfiller.backfillFolderpages a folder newest→oldest via
ImapClient.fetchOlderThan. It derives thenext boundary from the lowest currently-cached UID each batch (not a
stored cursor) and persists progress in the new
backfill_progresstableafter every
NonCancellablebatch write. A run stopped by process death /network loss / WorkManager cancellation resumes from the cached rows. It runs
off
MailSyncer's sync mutex so pull-to-refresh stays responsive. Pleasesanity-check the resume invariant and that headers are always persisted
before the boundary advances.
Backfill ↔ prune precedence — the rule is: backfill pauses (does not
mark the folder complete) once the account's retention floor is reached, and
the pruner only deletes below that same floor — disjoint working sets.
They also share a process-wide
MailMaintenanceGatemutex so they never runat once (defence in depth). Because backfill pauses (not completes) at the
floor, loosening retention later resumes filling automatically. Please review
reachedRetentionFloor+ the "pause vs complete" distinction.Server-load batching / backoff —
fetchOlderThanbounds both memory(one
BACKFILL_BATCH_SIZE=50 page materialized) and network (boundary foundby binary search over message numbers = O(log n) tiny
UID FETCHs, not afull lower-range scan). Each WorkManager run does ≤
DEFAULT_MAX_BATCHES=20pages with a 250 ms inter-page delay; periodic cadence is 30 min, battery-
not-low + network constrained. Is the batch/backoff shape acceptable for
large mailboxes? Note: body prefetch during backfill honours
FetchPolicy(default
ALWAYS) and can be heavy — flagging for a tuning opinion.Room schema migration (9 → 10) —
MIGRATION_9_10addsmessages.uid(backfilled from the id's numeric tail viartrim(id,'0123456789')), nullableaccount_settings.retentionCount/Months,and the
backfill_progresstable. Exported10.jsonis committed and aMigrationTestHelpertest (Migration9To10Test, instrumented) validates it.What changed
messages.uidmaterialized column powers a windowed deletionreconcile in
MailSyncer(deleteSyncedInWindowNotIn) so foreground sync nolonger wipes everything outside the recent 50 — backfilled history survives.
Backfill kicked on account-add and on a periodic schedule.
(
RetentionPolicy, 0 = keep everything).MailPrunerdeletes local rowsbeyond the limit (attachment rows cascade; on-disk cache removed; deletes
chunked under SQLite's 999-param limit) and never issues a server delete.
Settings UI for global + per-account with "device only, not the server" copy.
Self-review found & fixed
window → foreground fetch window is now capped by the retention count.
deleteByIdscould exceed SQLite's 999-parameter limit on a first large prune→ chunked.
SyncSchedulerDI param →updated.
Testing
Fast CI gate green:
assembleDebug+testDebugUnitTest+lintDebug+ktlintCheck+detekt. New JVM tests use GreenMail to prove the pagedbackfill caches >50 messages and resumes after an interruption
(
ImapClientBackfillTest,MailBackfillerTest), plus count/age pruning thatnever asks the server to delete (
MailPrunerTest) andRetentionPolicyTest.Left to CI / manual (needs an emulator): the
MigrationTestHelpermigrationtest and any Compose UI verification of the new retention settings + a backfill
progress indicator (no progress UI is included in this PR).
🤖 Generated with Claude Code