Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict CONFIRMED).
Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.
app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:197 — high
openMessage's cached-body/unread branch resolves IMAP connection params (which for OAuth accounts performs a network token refresh) on the critical path BEFORE marking read and returning the fully-cached body, so the open fails when that resolution throws.
Failure scenario: Outlook account, message body already cached but unread, device offline (or MS token endpoint failing) with an expired cached access token: connectionFactory.imapParamsFor → resolveSecret → cachedAccessToken attempts a network refresh and throws; the enclosing runCatching returns failure and the reader shows an error for a message that is entirely readable from the local cache — cached unread mail is unopenable offline in an offline-first client (read messages open fine, exposing the inconsistency). Same for a password account whose credential row is missing (MissingCredentialsException).
Verifier justification (CONFIRMED): The cached-body/unread branch of openMessage calls connectionFactory.imapParamsFor(account) at line 197 BEFORE setRead and before returning the fully-cached body, inside the single runCatching wrapping the whole open. imapParamsFor → resolveSecret throws for (a) OAuth accounts when the in-memory token cache is empty/expired: cachedAccessToken calls refresh(stored) (outlookAuthManager::freshOutlookToken), a network token redemption that fails offline; and (b) password accounts with a missing credential row (MissingCredentialsException). ReaderViewModel.fold's onFailure then shows an error instead of the locally-cached message. Trigger: Outlook account, body cached, message unread, fresh process (empty tokenCache), device offline → unopenable; the same message opens fine once marked read (the already-read path skips params entirely), proving the inconsistency. Not intentional: the comment at lines 192-196 explicitly states the branch is 'Optimistic, local-only: the reader can render as soon as this returns' and the IMAP round trip 'must not sit on this path (#148/#186)' — yet the credential resolution needed only for the background SEEN push sits synchronously and unguarded on that path.
Fix hint: In MailRepositoryImpl.openMessage's else-if branch, call messageDao.setRead(id, true) first, then move the imapParamsFor resolution off the critical path — either wrap it in runCatching (skipping the push on failure) or pass the Account into pushSeenFlagInBackground and resolve params inside the backgroundScope launch under its existing runCatching/retry loop.
Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict **CONFIRMED**).
**Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.**
## `app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:197` — high
openMessage's cached-body/unread branch resolves IMAP connection params (which for OAuth accounts performs a network token refresh) on the critical path BEFORE marking read and returning the fully-cached body, so the open fails when that resolution throws.
**Failure scenario:** Outlook account, message body already cached but unread, device offline (or MS token endpoint failing) with an expired cached access token: connectionFactory.imapParamsFor → resolveSecret → cachedAccessToken attempts a network refresh and throws; the enclosing runCatching returns failure and the reader shows an error for a message that is entirely readable from the local cache — cached unread mail is unopenable offline in an offline-first client (read messages open fine, exposing the inconsistency). Same for a password account whose credential row is missing (MissingCredentialsException).
**Verifier justification (CONFIRMED):** The cached-body/unread branch of openMessage calls connectionFactory.imapParamsFor(account) at line 197 BEFORE setRead and before returning the fully-cached body, inside the single runCatching wrapping the whole open. imapParamsFor → resolveSecret throws for (a) OAuth accounts when the in-memory token cache is empty/expired: cachedAccessToken calls refresh(stored) (outlookAuthManager::freshOutlookToken), a network token redemption that fails offline; and (b) password accounts with a missing credential row (MissingCredentialsException). ReaderViewModel.fold's onFailure then shows an error instead of the locally-cached message. Trigger: Outlook account, body cached, message unread, fresh process (empty tokenCache), device offline → unopenable; the same message opens fine once marked read (the already-read path skips params entirely), proving the inconsistency. Not intentional: the comment at lines 192-196 explicitly states the branch is 'Optimistic, local-only: the reader can render as soon as this returns' and the IMAP round trip 'must not sit on this path (#148/#186)' — yet the credential resolution needed only for the background SEEN push sits synchronously and unguarded on that path.
**Defective line:** `val params = connectionFactory.imapParamsFor(account)
messageDao.setRead(id, true)
pushSeenFlagInBackground(params, routing.folder, id)`
**Fix hint:** In MailRepositoryImpl.openMessage's else-if branch, call messageDao.setRead(id, true) first, then move the imapParamsFor resolution off the critical path — either wrap it in runCatching (skipping the push on failure) or pass the Account into pushSeenFlagInBackground and resolve params inside the backgroundScope launch under its existing runCatching/retry loop.
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.
Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict CONFIRMED).
Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.
app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:197— highopenMessage's cached-body/unread branch resolves IMAP connection params (which for OAuth accounts performs a network token refresh) on the critical path BEFORE marking read and returning the fully-cached body, so the open fails when that resolution throws.
Failure scenario: Outlook account, message body already cached but unread, device offline (or MS token endpoint failing) with an expired cached access token: connectionFactory.imapParamsFor → resolveSecret → cachedAccessToken attempts a network refresh and throws; the enclosing runCatching returns failure and the reader shows an error for a message that is entirely readable from the local cache — cached unread mail is unopenable offline in an offline-first client (read messages open fine, exposing the inconsistency). Same for a password account whose credential row is missing (MissingCredentialsException).
Verifier justification (CONFIRMED): The cached-body/unread branch of openMessage calls connectionFactory.imapParamsFor(account) at line 197 BEFORE setRead and before returning the fully-cached body, inside the single runCatching wrapping the whole open. imapParamsFor → resolveSecret throws for (a) OAuth accounts when the in-memory token cache is empty/expired: cachedAccessToken calls refresh(stored) (outlookAuthManager::freshOutlookToken), a network token redemption that fails offline; and (b) password accounts with a missing credential row (MissingCredentialsException). ReaderViewModel.fold's onFailure then shows an error instead of the locally-cached message. Trigger: Outlook account, body cached, message unread, fresh process (empty tokenCache), device offline → unopenable; the same message opens fine once marked read (the already-read path skips params entirely), proving the inconsistency. Not intentional: the comment at lines 192-196 explicitly states the branch is 'Optimistic, local-only: the reader can render as soon as this returns' and the IMAP round trip 'must not sit on this path (#148/#186)' — yet the credential resolution needed only for the background SEEN push sits synchronously and unguarded on that path.
Defective line:
val params = connectionFactory.imapParamsFor(account) messageDao.setRead(id, true) pushSeenFlagInBackground(params, routing.folder, id)Fix hint: In MailRepositoryImpl.openMessage's else-if branch, call messageDao.setRead(id, true) first, then move the imapParamsFor resolution off the critical path — either wrap it in runCatching (skipping the push on failure) or pass the Account into pushSeenFlagInBackground and resolve params inside the backgroundScope launch under its existing runCatching/retry loop.