AccountDao.upsert was @Insert(onConflict = REPLACE). On a PK conflict SQLite does delete-then-insert, firing the ON DELETE CASCADE on account_settings + signatures, so re-adding an existing account id (e.g. re-authing an Outlook account — deterministic id outlook:<email> — via addOutlookAccount → insertAtEnd → upsert) permanently wiped its signatures.
New insertIfAbsent (@Insert(IGNORE), returns rowid/-1) + @Update update; upsert is now insert-if-absent-else-update-in-place — never REPLACE, so no cascade.
insertAtEnd now reads the row first: new id → append at nextSortOrder(); existing id → update in place preserving its sortOrder (stays put instead of jumping to the end). reorder is untouched.
Account ids were built from a trim-only, non-lowercased email, so User@Gmail.com then user@gmail.com created two rows syncing one mailbox.
New normalizeEmailForAccountId(email, lowercaseLocalPart) always lowercases the domain; lowercases the whole address only for consumer providers (which are case-insensitive). Local part is left as typed for generic manual IMAP (RFC 5321 allows a case-sensitive local part).
Applied at every id-derivation site: MailProvider.createAccount (consumer, whole), Account.outlook (consumer, whole), ManualSetupViewModel (generic, domain-only). The displayed email keeps the user's casing.
deleteAccount removed DB rows but left each message's on-disk attachment cache (plaintext, keyed by message id) and never released drafts' persistable URI grants — unrecoverable once the rows are gone.
Collects the account's message ids (MessageDao.getIdsForAccount) and draft attachment URIs before deleting rows, then deleteRecursively()s each attachmentCacheDir and calls AttachmentUriGrants.releaseUnreferenced.
Also deletes the account's drafts (DraftDao.deleteByAccount) so their grants actually become unreferenced (and orphaned drafts don't linger).
Tests
Unit: normalizeEmailForAccountId + Account.outlook id (AccountTest), casing-dedupe in MailProviderTest, domain-lowercasing in ManualSetupViewModelTest, deleteAccount purges caches + releases grants (AccountRepositoryImplTest, real temp cacheDir).
Instrumented (DAO): re-add via upsert/insertAtEnd keeps settings + signatures + list position (AccountDatabaseTest); AccountDaoTest upsert test renamed to the non-destructive semantics.
Validation
:app:testDebugUnitTest + :app:compileDebugAndroidTestKotlin + :app:ktlintCheck + :app:detekt all green. AccountDatabaseTest + AccountDaoTest (11 tests) green on a locally cold-booted API 36 emulator via local_instrumented.sh.
Fixes three account-lifecycle data-integrity findings from the whole-repo review. All in the account create/delete path.
## #309 — non-destructive upsert (MEDIUM, data-loss)
`AccountDao.upsert` was `@Insert(onConflict = REPLACE)`. On a PK conflict SQLite does delete-then-insert, firing the `ON DELETE CASCADE` on `account_settings` + `signatures`, so re-adding an existing account id (e.g. re-authing an Outlook account — deterministic id `outlook:<email>` — via `addOutlookAccount` → `insertAtEnd` → `upsert`) permanently wiped its signatures.
- New `insertIfAbsent` (`@Insert(IGNORE)`, returns rowid/-1) + `@Update update`; `upsert` is now insert-if-absent-else-update-in-place — never REPLACE, so no cascade.
- `insertAtEnd` now reads the row first: new id → append at `nextSortOrder()`; existing id → `update` in place **preserving its `sortOrder`** (stays put instead of jumping to the end). `reorder` is untouched.
## #305 — id normalization (LOW, correctness)
Account ids were built from a trim-only, non-lowercased email, so `User@Gmail.com` then `user@gmail.com` created two rows syncing one mailbox.
- New `normalizeEmailForAccountId(email, lowercaseLocalPart)` always lowercases the domain; lowercases the whole address only for consumer providers (which are case-insensitive). Local part is left as typed for generic manual IMAP (RFC 5321 allows a case-sensitive local part).
- Applied at every id-derivation site: `MailProvider.createAccount` (consumer, whole), `Account.outlook` (consumer, whole), `ManualSetupViewModel` (generic, domain-only). The displayed `email` keeps the user's casing.
## #299 — deleteAccount cleanup (MEDIUM, correctness + security)
`deleteAccount` removed DB rows but left each message's on-disk attachment cache (plaintext, keyed by message id) and never released drafts' persistable URI grants — unrecoverable once the rows are gone.
- Collects the account's message ids (`MessageDao.getIdsForAccount`) and draft attachment URIs **before** deleting rows, then `deleteRecursively()`s each `attachmentCacheDir` and calls `AttachmentUriGrants.releaseUnreferenced`.
- Also deletes the account's drafts (`DraftDao.deleteByAccount`) so their grants actually become unreferenced (and orphaned drafts don't linger).
## Tests
- Unit: `normalizeEmailForAccountId` + `Account.outlook` id (`AccountTest`), casing-dedupe in `MailProviderTest`, domain-lowercasing in `ManualSetupViewModelTest`, deleteAccount purges caches + releases grants (`AccountRepositoryImplTest`, real temp cacheDir).
- Instrumented (DAO): re-add via `upsert`/`insertAtEnd` keeps settings + signatures + list position (`AccountDatabaseTest`); `AccountDaoTest` upsert test renamed to the non-destructive semantics.
## Validation
`:app:testDebugUnitTest` + `:app:compileDebugAndroidTestKotlin` + `:app:ktlintCheck` + `:app:detekt` all green. `AccountDatabaseTest` + `AccountDaoTest` (11 tests) green on a locally cold-booted API 36 emulator via `local_instrumented.sh`.
Closes #309
Closes #305
Closes #299
🤖 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.
Fixes three account-lifecycle data-integrity findings from the whole-repo review. All in the account create/delete path.
#309 — non-destructive upsert (MEDIUM, data-loss)
AccountDao.upsertwas@Insert(onConflict = REPLACE). On a PK conflict SQLite does delete-then-insert, firing theON DELETE CASCADEonaccount_settings+signatures, so re-adding an existing account id (e.g. re-authing an Outlook account — deterministic idoutlook:<email>— viaaddOutlookAccount→insertAtEnd→upsert) permanently wiped its signatures.insertIfAbsent(@Insert(IGNORE), returns rowid/-1) +@Update update;upsertis now insert-if-absent-else-update-in-place — never REPLACE, so no cascade.insertAtEndnow reads the row first: new id → append atnextSortOrder(); existing id →updatein place preserving itssortOrder(stays put instead of jumping to the end).reorderis untouched.#305 — id normalization (LOW, correctness)
Account ids were built from a trim-only, non-lowercased email, so
User@Gmail.comthenuser@gmail.comcreated two rows syncing one mailbox.normalizeEmailForAccountId(email, lowercaseLocalPart)always lowercases the domain; lowercases the whole address only for consumer providers (which are case-insensitive). Local part is left as typed for generic manual IMAP (RFC 5321 allows a case-sensitive local part).MailProvider.createAccount(consumer, whole),Account.outlook(consumer, whole),ManualSetupViewModel(generic, domain-only). The displayedemailkeeps the user's casing.#299 — deleteAccount cleanup (MEDIUM, correctness + security)
deleteAccountremoved DB rows but left each message's on-disk attachment cache (plaintext, keyed by message id) and never released drafts' persistable URI grants — unrecoverable once the rows are gone.MessageDao.getIdsForAccount) and draft attachment URIs before deleting rows, thendeleteRecursively()s eachattachmentCacheDirand callsAttachmentUriGrants.releaseUnreferenced.DraftDao.deleteByAccount) so their grants actually become unreferenced (and orphaned drafts don't linger).Tests
normalizeEmailForAccountId+Account.outlookid (AccountTest), casing-dedupe inMailProviderTest, domain-lowercasing inManualSetupViewModelTest, deleteAccount purges caches + releases grants (AccountRepositoryImplTest, real temp cacheDir).upsert/insertAtEndkeeps settings + signatures + list position (AccountDatabaseTest);AccountDaoTestupsert test renamed to the non-destructive semantics.Validation
:app:testDebugUnitTest+:app:compileDebugAndroidTestKotlin+:app:ktlintCheck+:app:detektall green.AccountDatabaseTest+AccountDaoTest(11 tests) green on a locally cold-booted API 36 emulator vialocal_instrumented.sh.Closes #309
Closes #305
Closes #299
🤖 Generated with Claude Code