fix(mail): openMessage does OAuth token refresh before serving a fully-cached body #485

Open
opened 2026-07-10 19:14:23 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-10 19:14:23 +00:00 (Migrated from github.com)

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.

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.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#485