fix(data): account-lifecycle data integrity (non-destructive upsert, id normalization, deleteAccount cleanup) #315

Merged
JMR-dev merged 4 commits from fix-account-lifecycle-integrity into main 2026-07-04 08:00:45 +00:00
JMR-dev commented 2026-07-04 07:13:57 +00:00 (Migrated from github.com)

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

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)
Sign in to join this conversation.