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_settingsdoes 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 saveSecretbeforeinsertAtEnd 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.
## 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
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.
Problem (#403)
At account-add,
IdleService(the IMAP IDLE push service) could fire a failed first IDLE attempt loggingNo stored credentials … (MailConnectionFactory.resolveSecret). Root cause is a write-ordering race:AccountRepositoryImpl.addImapAccount/addOutlookAccountinserted the account row before persisting the credential. BothLibreMailApplication's push collector andIdleService.reconcileWatchers()react to the accounts table, so a watcher could observe the new account and callresolveSecret()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
credentialstable has no foreign key toaccounts(confirmed in the exported schema), so it can be written first;account_settingsdoes have an FK, soensureDefaultsstill 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:
resolveSecretnow throws a typedMissingCredentialsException(extendsIllegalStateException, so existing hard-error callers are unaffected; message is PII-free — the olderror("…${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 thewarn+ exponential backoff a real connection drop gets. Genuinely-absent credentials keep deferring quietly.Logging
PII-free
AppLogat the new decision points, usingaccountLogRef(account.id)(never email/host/token): account-added breadcrumb in the repository, and the deferral breadcrumb in the watcher.Tests
AccountRepositoryImplTest— newcoVerifyOrdertests pinsaveSecretbeforeinsertAtEndfor both add paths (proves the primary fix). Added themockkStatic(Log)setup the new breadcrumb requires.MailConnectionFactoryTest— the two missing-credential tests now assert the typedMissingCredentialsException(the mechanism the watcher tolerance relies on).AccountAddCredentialOrderingInstrumentedTest(new, androidTest) — drives the real repository add path against a real in-memoryAccountDatabase+ real Keystore-backedCredentialStore, 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
IdleServiceis 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
Merge Queue Status
2026-07-08 04:42 UTC· Rule:default· triggered by merge protections2026-07-08 05:05 UTC· at51dd27844190f79488653d6c47d7b8379c3b39f4· mergeThis pull request spent 23 minutes 20 seconds in the queue, including 23 minutes 7 seconds running CI.
Required conditions to merge
-conflict-draftbase = maincheck-success = CI passedgithub-review-approved[🛡 GitHub repository ruleset rulemain]label != brokencheck-success = Debug buildcheck-neutral = Debug buildcheck-skipped = Debug buildcheck-success = Unit testscheck-neutral = Unit testscheck-skipped = Unit testscheck-success = CI passedcheck-neutral = CI passedcheck-skipped = CI passedmain]:check-success = @github-actions/CI passedcheck-neutral = @github-actions/CI passedcheck-skipped = @github-actions/CI passed