fix(push): persist credentials before IdleService watches a new account (#403) #427

Merged
JMR-dev merged 2 commits from fix-403-idleservice-credential-race into main 2026-07-08 05:05:40 +00:00
JMR-dev commented 2026-07-08 04:18:23 +00:00 (Migrated from github.com)

Problem (#403)

At account-add, IdleService (the IMAP IDLE push service) could fire a failed first IDLE attempt logging No stored credentials … (MailConnectionFactory.resolveSecret). Root cause is a write-ordering race: AccountRepositoryImpl.addImapAccount / addOutlookAccount inserted the account row before persisting the credential. Both LibreMailApplication's push collector and IdleService.reconcileWatchers() react to the accounts table, so a watcher could observe the new account and call resolveSecret() before its secret existed. It self-healed on retry, but fired a failed IDLE + log noise on every add.

Fix

Primary (ordering) — closes the race at its source: Reorder the writes so the credential is committed before the account row. The credentials table has no foreign key to accounts (confirmed in the exported schema), so it can be written first; account_settings does have an FK, so ensureDefaults still runs after the insert. Because every reactive path keys off the account row, and the credential commits first, any observer that sees the new row is guaranteed to resolve its secret.

Defense-in-depth (tolerance) — kills residual noise: resolveSecret now throws a typed MissingCredentialsException (extends IllegalStateException, so existing hard-error callers are unaffected; message is PII-free — the old error("…${account.email}") leaked the email). The IDLE watcher catches it specifically and treats it as a transient miss: a short, flat re-check with a PII-free info log (IDLE deferred <ref>: credentials not yet persisted), instead of the warn + exponential backoff a real connection drop gets. Genuinely-absent credentials keep deferring quietly.

Logging

PII-free AppLog at the new decision points, using accountLogRef(account.id) (never email/host/token): account-added breadcrumb in the repository, and the deferral breadcrumb in the watcher.

Tests

  • AccountRepositoryImplTest — new coVerifyOrder tests pin saveSecret before insertAtEnd for both add paths (proves the primary fix). Added the mockkStatic(Log) setup the new breadcrumb requires.
  • MailConnectionFactoryTest — the two missing-credential tests now assert the typed MissingCredentialsException (the mechanism the watcher tolerance relies on).
  • AccountAddCredentialOrderingInstrumentedTest (new, androidTest) — drives the real repository add path against a real in-memory AccountDatabase + real Keystore-backed CredentialStore, with a background collector that reads the secret the instant the new account row becomes observable — proving it resolves. Compiles locally; runs on CI's matrix.

Gate (local, JDK 21)

assembleDebug + testDebugUnitTest (1387 pass) + jacocoTestCoverageVerification (floor 0.84 held) + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt — all green. Local emulator E2E unavailable on this box; instrumented test validated by CI.

Notes / assumptions

  • The reorder fully closes the reported reactive race, so the tolerance is belt-and-suspenders (robustness against future regressions + the exact log-noise reduction #403 asks for).
  • IdleService is coverage-excluded (**/*Service*), so the watcher branch doesn't affect the coverage floor; its behavior is exercised on-device via the existing instrumented start-seam pattern.

Closes #403

## Problem (#403) At account-add, `IdleService` (the IMAP IDLE push service) could fire a failed first IDLE attempt logging `No stored credentials … (MailConnectionFactory.resolveSecret)`. Root cause is a write-ordering race: `AccountRepositoryImpl.addImapAccount` / `addOutlookAccount` inserted the **account row before** persisting the credential. Both `LibreMailApplication`'s push collector and `IdleService.reconcileWatchers()` react to the **accounts** table, so a watcher could observe the new account and call `resolveSecret()` before its secret existed. It self-healed on retry, but fired a failed IDLE + log noise on every add. ## Fix **Primary (ordering) — closes the race at its source:** Reorder the writes so the credential is committed **before** the account row. The `credentials` table has no foreign key to `accounts` (confirmed in the exported schema), so it can be written first; `account_settings` *does* have an FK, so `ensureDefaults` still runs after the insert. Because every reactive path keys off the account row, and the credential commits first, any observer that sees the new row is guaranteed to resolve its secret. **Defense-in-depth (tolerance) — kills residual noise:** `resolveSecret` now throws a typed `MissingCredentialsException` (extends `IllegalStateException`, so existing hard-error callers are unaffected; message is PII-free — the old `error("…${account.email}")` leaked the email). The IDLE watcher catches it specifically and treats it as a **transient miss**: a short, flat re-check with a PII-free info log (`IDLE deferred <ref>: credentials not yet persisted`), instead of the `warn` + exponential backoff a real connection drop gets. Genuinely-absent credentials keep deferring quietly. ## Logging PII-free `AppLog` at the new decision points, using `accountLogRef(account.id)` (never email/host/token): account-added breadcrumb in the repository, and the deferral breadcrumb in the watcher. ## Tests - **`AccountRepositoryImplTest`** — new `coVerifyOrder` tests pin `saveSecret` **before** `insertAtEnd` for both add paths (proves the primary fix). Added the `mockkStatic(Log)` setup the new breadcrumb requires. - **`MailConnectionFactoryTest`** — the two missing-credential tests now assert the typed `MissingCredentialsException` (the mechanism the watcher tolerance relies on). - **`AccountAddCredentialOrderingInstrumentedTest`** (new, androidTest) — drives the real repository add path against a real in-memory `AccountDatabase` + real Keystore-backed `CredentialStore`, with a background collector that reads the secret the instant the new account row becomes observable — proving it resolves. Compiles locally; runs on CI's matrix. ## Gate (local, JDK 21) `assembleDebug` + `testDebugUnitTest` (1387 pass) + `jacocoTestCoverageVerification` (floor 0.84 held) + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` + `detekt` — all green. Local emulator E2E unavailable on this box; instrumented test validated by CI. ## Notes / assumptions - The reorder fully closes the reported reactive race, so the tolerance is belt-and-suspenders (robustness against future regressions + the exact log-noise reduction #403 asks for). - `IdleService` is coverage-excluded (`**/*Service*`), so the watcher branch doesn't affect the coverage floor; its behavior is exercised on-device via the existing instrumented start-seam pattern. Closes #403
mergify[bot] commented 2026-07-08 04:42:22 +00:00 (Migrated from github.com)

Merge Queue Status

  • ✅ Entered queue — 2026-07-08 04:42 UTC · Rule: default · triggered by merge protections
  • ✅ Checks passed · in-place
  • ✅ Merged — 2026-07-08 05:05 UTC · at 51dd27844190f79488653d6c47d7b8379c3b39f4 · merge

This pull request spent 23 minutes 20 seconds in the queue, including 23 minutes 7 seconds running CI.

Required conditions to merge
<!--- DO NOT EDIT -*- Mergify Payload -*- {"version": 1, "state": "merged", "queue_rule_name": "default", "queued_at": "2026-07-08T04:42:20.870549+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []} -*- Mergify Payload End -*- --> # Merge Queue Status - ✅ **Entered queue** — `2026-07-08 04:42 UTC` · Rule: `default` · triggered by merge protections - ✅ **Checks passed** · in-place - ✅ **Merged** — `2026-07-08 05:05 UTC` · at `51dd27844190f79488653d6c47d7b8379c3b39f4` · merge This pull request spent **23 minutes 20 seconds** in the queue, including **23 minutes 7 seconds** running CI. <details> <summary>Required conditions to merge</summary> - `-conflict` - [X] #427 - `-draft` - [X] #427 - [X] `base = main` - [X] `check-success = CI passed` - `github-review-approved` [🛡 GitHub repository ruleset rule `main`] - [X] #427 - `label != broken` - [X] #427 - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Debug build` - [ ] `check-neutral = Debug build` - [ ] `check-skipped = Debug build` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Unit tests` - [ ] `check-neutral = Unit tests` - [ ] `check-skipped = Unit tests` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = CI passed` - [ ] `check-neutral = CI passed` - [ ] `check-skipped = CI passed` - [X] any of [🛡 GitHub repository ruleset rule `main`]: - [X] `check-success = @github-actions/CI passed` - [ ] `check-neutral = @github-actions/CI passed` - [ ] `check-skipped = @github-actions/CI passed` </details>
Sign in to join this conversation.