Source: whole-repo multi-agent review, 2026-07-09 (run wf_b41de68c-e85). The run was cut short by usage limits before its verification pass, so every finding below is an unverified finder candidate — validate each against the current code before implementing. Findings are listed medium first, then low. Critical/high candidates from the same run were verified separately and have their own issues.
Medium (1)
app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt:148 — Tapping an already-selected retention radio (a no-op change) still runs the full pipeline: DataStore write, pruneNow() across all accounts, and resetBackfillProgress(null), which deletes every account's backfill-progress rows and schedules an immediate network backfill that re-pages folder history for all accounts.
severity: medium · category: efficiency · angle: efficiency
User opens Settings and taps the currently-selected 'Keep everything' option (or re-taps any selected retention radio; RetentionGroup in SettingsComponents.kt:174-180 calls onChange unconditionally, and setRetentionCount never compares with the stored value). backfillProgressDao.deleteAll() + syncScheduler.backfillNow() then re-page mailbox history over IMAP for every folder of every account — thousands of wasted IMAP commands plus a prune scan, on providers already known to throttle this app (Gmail). Cheaper alternative: skip in the ViewModel when the new value equals settings.first()'s current value (and guard RadioRow's onClick when selected == true). Same defect per-account in AccountSettingsViewModel.setRetentionCount/Months (lines 78-92).
Low (12)
app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt:88 — The onFailure lambda in AppPasswordViewModel.testAndSave re-implements ManualSetupViewModel.onAddFailure (ManualSetupViewModel.kt:104-112) nearly verbatim: imapDisabledPromptFor(e, account, usedOAuth = false), the AppLog.i 'IMAP disabled ... prompting to enable IMAP' breadcrumb, the status=IDLE + imapDisabledPrompt update, and the identical generic fallback string 'Could not connect to the server'.
severity: low · category: reuse · angle: reuse
The #390 failure-routing policy is duplicated in two view models over structurally identical form states. If the classification or UX changes (e.g. a new ImapAuthError case, a changed fallback message, extra logging, or a retry affordance), one copy is updated and the other silently diverges, so the app-password and manual setup paths handle the same server rejection differently. Fix: extract one shared helper (e.g. next to imapDisabledPromptFor in ImapDisabledPrompt.kt) that classifies the throwable and returns the prompt-or-message outcome, leaving each VM only the one-line state update.
app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt:97 — Generic account-setup failure path has no AppLog breadcrumb: CLAUDE.md's Definition of done requires logging at 'error/fallback paths', but AppPasswordViewModel.testAndSave's onFailure else-branch (line 97) and ManualSetupViewModel.onAddFailure's else-branch (ManualSetupViewModel.kt line 110) log nothing, and the chain below (AccountRepositoryImpl.addImapAccount wraps everything in runCatching, logging only success at line 68) logs nothing either — only the narrow IMAP-disabled subcase is logged.
User enters a wrong app password or an unreachable IMAP host during setup; the connection test fails, a snackbar shows e.message, and the user submits a DebugReport saying 'can't add my account' — the RingLogBuffer contains zero trace of the attempt or the failure cause (the sibling Outlook flow, AccountSetupViewModel.kt line 106, logs the same class of failure), so the top support scenario is undiagnosable from the report.
app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt:61 — The paired LaunchedEffect blocks — status==DONE -> addedAccountId?.let(onAccountAdded), and error -> showSnackbar + consumeError — are copy-pasted four times: here (61-71), AppPasswordSetupScreen.kt:88-98, AccountPickerScreen.kt:94-104, and OutlookImapNoticeScreen.kt:88-98, all over the same SetupStatus/error/addedAccountId state shape.
severity: low · category: reuse · angle: reuse
Four identical implementations of the setup-completion/error-surfacing contract must be kept in sync by hand. A fix to one copy (e.g. also keying on a one-shot token so a re-composition can't re-deliver onAccountAdded, or changing snackbar duration) has to be repeated in the other three or the setup flows drift in navigation/error behavior. Fix: one shared composable, e.g. SetupResultEffects(status, addedAccountId, error, snackbarHostState, onAccountAdded, onConsumeError) in ui/accountsetup, used by all four screens.
app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt:246 — private fun MailSecurity.label() here is an identical copy of the private extension of the same name in AppPasswordSetupScreen.kt:291-295; the duplication exists only because both are file-private.
severity: low · category: reuse · angle: reuse
These are the user-facing display names for the transport-security options. If one is edited (e.g. relabeling 'SSL/TLS' to 'Implicit TLS', or handling a new MailSecurity entry), the manual-setup chips and the app-password 'preset servers' summary show different names for the same enum value. Fix: promote one internal MailSecurity.label() (in the ui/accountsetup package, or as string resources) and delete the other copy.
app/src/main/kotlin/org/libremail/ui/onboarding/BatteryGuideAnimation.kt:106 — The infinite-transition values (tapAlpha every frame, phase) are read in composition and passed as plain parameters, so BatteryGuideAnimation, GuideCard, and the focused GuideRow recompose on every animation frame for as long as the onboarding battery screen is visible.
User sits on BatteryOptimizationScreen reading the guidance (easily 30+ seconds): the 900ms reverse-repeating tapAlpha tween invalidates the composition ~60-120 times per second, re-running GuideCard's layout tree each frame — sustained main-thread churn and battery drain on the very screen asking for battery trust. Cheaper alternative: pass the animated values as State/lambda providers and consume them in the draw phase (Modifier.graphicsLayer { alpha = tapAlpha() } / drawBehind for the highlight), so the animation runs draw-only with zero recomposition.
app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt:128 — The READ_CONTACTS permission outcome is folded into state with no logging anywhere in the feature: onContactsPermissionResult (and the whole org.libremail.contacts package, which contains zero AppLog calls) records grant/denial silently, violating CLAUDE.md's Definition of done requirement that significant state changes be diagnosable from a debug report.
A user denies contacts access during onboarding (or the grant is later revoked), then reports 'recipient autocomplete never suggests anyone'; the debug report contains no breadcrumb of the permission request, its result, or the prompt-handled transition — while the neighbouring battery opt-in step logs every branch (BatteryOptimizationScreen lines 70-145), leaving this the only onboarding opt-in whose outcome cannot be reconstructed.
app/src/main/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreen.kt:174 — The Sign-in button's onClick duplicates AccountPickerScreen's launchOutlookInline lambda (AccountPickerScreen.kt:84-92) verbatim — outlookAuthIntent().fold with runCatching { outlookLauncher.launch(intent) } and onOutlookLaunchFailed on both failure paths — and the screen's openUrl helper (lines 101-104) likewise duplicates AppPasswordSetupScreen's openUrl (AppPasswordSetupScreen.kt:83-86) including the pre-resolved openFailedMessage pattern.
severity: low · category: reuse · angle: reuse
The Outlook launch sequence exists in two copies over the same AccountSetupViewModel. Any change to the launch flow — e.g. guarding against double-launch while busy, logging, or new AppAuth error mapping — applied to the picker's copy leaves onboarding's pre-auth-notice copy behaving differently (or vice versa), even though the doc comment asserts they run 'this exact same flow'. Fix: extract a shared rememberOutlookSignInLauncher()/launchOutlookSignIn(viewModel, launcher) helper in ui/accountsetup, and similarly a shared snackbar-reporting openUrl helper.
app/src/main/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreen.kt:206 — IMAP_HELP_URL is a byte-for-byte copy of OUTLOOK_IMAP_HELP_URL in app/src/main/kotlin/org/libremail/ui/accountsetup/ImapDisabledPrompt.kt:46-48 — both files even carry comments promising the two 'point users to one page' (#411/#390), but that invariant is enforced only by comment, not by sharing the constant.
severity: low · category: reuse · angle: reuse
When Microsoft retires or moves the support article and someone updates the URL found by grep in ImapDisabledPrompt.kt, the onboarding pre-auth notice keeps linking the stale/dead page (or vice versa), breaking exactly the both-directions-one-page invariant the comments document. Fix: hoist one shared internal constant (e.g. in ImapDisabledPrompt.kt or MailProvider) and reference it from both sites.
app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt:53 — signatureCount and defaultSignatureName each stateIn the same cold Room flow (signatureRepository.observeForAccount), registering two identical Room query observers that both re-execute the query and re-map every row to domain on each signature-table change.
While the account-settings screen is open, every signature insert/update/delete (or any invalidation of the signatures table) runs the observeForAccount query twice and maps the result list twice — duplicated DB work for the screen's lifetime. Cheaper alternative: stateIn/shareIn the list once (e.g. a single StateFlow<List>) and derive both count and default-name via .map off that shared hot flow.
app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt:115 — Account removal — deleting the account row, stored credential, all cached mail/folders/drafts, attachment cache, and the notification channel — produces no AppLog entry anywhere in the chain (neither here nor in AccountRepositoryImpl.deleteAccount), violating CLAUDE.md's Definition of done for 'lifecycle transitions ... significant state changes' (a PII-free log via accountLogRef(accountId) is the prescribed pattern, used by the corresponding account-ADD paths).
A user reports 'all my mail for an account vanished' (e.g. an accidental tap on Remove account, or a partially-failed deletion where credentialStore.delete succeeded but a later step threw mid-sequence); the debug report shows account-added breadcrumbs but no trace that a removal was ever initiated or how far it got, making the data-loss report impossible to reconstruct.
app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt:127 — setAppLock swallows the Keystore reseal exception with runCatching{...}.isSuccess and logs nothing on either the failure fallback (line 132 keeps app-lock on and shows a snackbar) or the enable/disable state change itself — violating CLAUDE.md's Definition of done ('error/fallback paths, significant state changes' must be logged; throwables passed to AppLog are auto-scrubbed, so the exception could safely be logged).
A user with an auth-sealed encrypted cache toggles app-lock off; databaseKeyStore.sealWithMaster() throws (e.g. KeyPermanentlyInvalidatedException after enrolling a new fingerprint); the user repeatedly sees 'app_lock_disable_failed' and files a debug report — the report contains no record of the reseal attempt, the thrown exception, or even that app-lock was being toggled, unlike the analogous paths in AppLockViewModel which log every seal/unseal failure. DatabaseKeyStore itself contains no AppLog calls, so nothing downstream covers it.
app/src/main/kotlin/org/libremail/ui/settings/SignaturesScreen.kt:113 — SignatureRow calls signature.plainText() (HtmlToText.convert HTML parsing) inline in composition with no remember, re-parsing each visible signature's HTML on every recomposition of the row.
On the signatures list, any state change that recomposes the rows (e.g. tapping the default radio, which flips isDefault on two rows and re-emits the whole list) re-runs the HTML-to-text conversion for every visible signature on the main thread; a long HTML signature makes each pass non-trivial. Cheaper alternative: remember(signature.html) { signature.plainText().replace('\n', ' ').trim() } so the parse runs once per content change.
Source: whole-repo multi-agent review, 2026-07-09 (run `wf_b41de68c-e85`). The run was cut short by usage limits before its verification pass, so every finding below is an **unverified finder candidate** — validate each against the current code before implementing. Findings are listed medium first, then low. Critical/high candidates from the same run were verified separately and have their own issues.
## Medium (1)
### `app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt:148` — Tapping an already-selected retention radio (a no-op change) still runs the full pipeline: DataStore write, pruneNow() across all accounts, and resetBackfillProgress(null), which deletes every account's backfill-progress rows and schedules an immediate network backfill that re-pages folder history for all accounts.
- severity: **medium** · category: `efficiency` · angle: `efficiency`
> User opens Settings and taps the currently-selected 'Keep everything' option (or re-taps any selected retention radio; RetentionGroup in SettingsComponents.kt:174-180 calls onChange unconditionally, and setRetentionCount never compares with the stored value). backfillProgressDao.deleteAll() + syncScheduler.backfillNow() then re-page mailbox history over IMAP for every folder of every account — thousands of wasted IMAP commands plus a prune scan, on providers already known to throttle this app (Gmail). Cheaper alternative: skip in the ViewModel when the new value equals settings.first()'s current value (and guard RadioRow's onClick when selected == true). Same defect per-account in AccountSettingsViewModel.setRetentionCount/Months (lines 78-92).
## Low (12)
### `app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt:88` — The onFailure lambda in AppPasswordViewModel.testAndSave re-implements ManualSetupViewModel.onAddFailure (ManualSetupViewModel.kt:104-112) nearly verbatim: imapDisabledPromptFor(e, account, usedOAuth = false), the AppLog.i 'IMAP disabled ... prompting to enable IMAP' breadcrumb, the status=IDLE + imapDisabledPrompt update, and the identical generic fallback string 'Could not connect to the server'.
- severity: **low** · category: `reuse` · angle: `reuse`
> The #390 failure-routing policy is duplicated in two view models over structurally identical form states. If the classification or UX changes (e.g. a new ImapAuthError case, a changed fallback message, extra logging, or a retry affordance), one copy is updated and the other silently diverges, so the app-password and manual setup paths handle the same server rejection differently. Fix: extract one shared helper (e.g. next to imapDisabledPromptFor in ImapDisabledPrompt.kt) that classifies the throwable and returns the prompt-or-message outcome, leaving each VM only the one-line state update.
### `app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt:97` — Generic account-setup failure path has no AppLog breadcrumb: CLAUDE.md's Definition of done requires logging at 'error/fallback paths', but AppPasswordViewModel.testAndSave's onFailure else-branch (line 97) and ManualSetupViewModel.onAddFailure's else-branch (ManualSetupViewModel.kt line 110) log nothing, and the chain below (AccountRepositoryImpl.addImapAccount wraps everything in runCatching, logging only success at line 68) logs nothing either — only the narrow IMAP-disabled subcase is logged.
- severity: **low** · category: `conventions` · angle: `conventions`
> User enters a wrong app password or an unreachable IMAP host during setup; the connection test fails, a snackbar shows e.message, and the user submits a DebugReport saying 'can't add my account' — the RingLogBuffer contains zero trace of the attempt or the failure cause (the sibling Outlook flow, AccountSetupViewModel.kt line 106, logs the same class of failure), so the top support scenario is undiagnosable from the report.
### `app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt:61` — The paired LaunchedEffect blocks — status==DONE -> addedAccountId?.let(onAccountAdded), and error -> showSnackbar + consumeError — are copy-pasted four times: here (61-71), AppPasswordSetupScreen.kt:88-98, AccountPickerScreen.kt:94-104, and OutlookImapNoticeScreen.kt:88-98, all over the same SetupStatus/error/addedAccountId state shape.
- severity: **low** · category: `reuse` · angle: `reuse`
> Four identical implementations of the setup-completion/error-surfacing contract must be kept in sync by hand. A fix to one copy (e.g. also keying on a one-shot token so a re-composition can't re-deliver onAccountAdded, or changing snackbar duration) has to be repeated in the other three or the setup flows drift in navigation/error behavior. Fix: one shared composable, e.g. SetupResultEffects(status, addedAccountId, error, snackbarHostState, onAccountAdded, onConsumeError) in ui/accountsetup, used by all four screens.
### `app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt:246` — private fun MailSecurity.label() here is an identical copy of the private extension of the same name in AppPasswordSetupScreen.kt:291-295; the duplication exists only because both are file-private.
- severity: **low** · category: `reuse` · angle: `reuse`
> These are the user-facing display names for the transport-security options. If one is edited (e.g. relabeling 'SSL/TLS' to 'Implicit TLS', or handling a new MailSecurity entry), the manual-setup chips and the app-password 'preset servers' summary show different names for the same enum value. Fix: promote one internal MailSecurity.label() (in the ui/accountsetup package, or as string resources) and delete the other copy.
### `app/src/main/kotlin/org/libremail/ui/onboarding/BatteryGuideAnimation.kt:106` — The infinite-transition values (tapAlpha every frame, phase) are read in composition and passed as plain parameters, so BatteryGuideAnimation, GuideCard, and the focused GuideRow recompose on every animation frame for as long as the onboarding battery screen is visible.
- severity: **low** · category: `efficiency` · angle: `efficiency`
> User sits on BatteryOptimizationScreen reading the guidance (easily 30+ seconds): the 900ms reverse-repeating tapAlpha tween invalidates the composition ~60-120 times per second, re-running GuideCard's layout tree each frame — sustained main-thread churn and battery drain on the very screen asking for battery trust. Cheaper alternative: pass the animated values as State/lambda providers and consume them in the draw phase (Modifier.graphicsLayer { alpha = tapAlpha() } / drawBehind for the highlight), so the animation runs draw-only with zero recomposition.
### `app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt:128` — The READ_CONTACTS permission outcome is folded into state with no logging anywhere in the feature: onContactsPermissionResult (and the whole org.libremail.contacts package, which contains zero AppLog calls) records grant/denial silently, violating CLAUDE.md's Definition of done requirement that significant state changes be diagnosable from a debug report.
- severity: **low** · category: `conventions` · angle: `conventions`
> A user denies contacts access during onboarding (or the grant is later revoked), then reports 'recipient autocomplete never suggests anyone'; the debug report contains no breadcrumb of the permission request, its result, or the prompt-handled transition — while the neighbouring battery opt-in step logs every branch (BatteryOptimizationScreen lines 70-145), leaving this the only onboarding opt-in whose outcome cannot be reconstructed.
### `app/src/main/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreen.kt:174` — The Sign-in button's onClick duplicates AccountPickerScreen's launchOutlookInline lambda (AccountPickerScreen.kt:84-92) verbatim — outlookAuthIntent().fold with runCatching { outlookLauncher.launch(intent) } and onOutlookLaunchFailed on both failure paths — and the screen's openUrl helper (lines 101-104) likewise duplicates AppPasswordSetupScreen's openUrl (AppPasswordSetupScreen.kt:83-86) including the pre-resolved openFailedMessage pattern.
- severity: **low** · category: `reuse` · angle: `reuse`
> The Outlook launch sequence exists in two copies over the same AccountSetupViewModel. Any change to the launch flow — e.g. guarding against double-launch while busy, logging, or new AppAuth error mapping — applied to the picker's copy leaves onboarding's pre-auth-notice copy behaving differently (or vice versa), even though the doc comment asserts they run 'this exact same flow'. Fix: extract a shared rememberOutlookSignInLauncher()/launchOutlookSignIn(viewModel, launcher) helper in ui/accountsetup, and similarly a shared snackbar-reporting openUrl helper.
### `app/src/main/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreen.kt:206` — IMAP_HELP_URL is a byte-for-byte copy of OUTLOOK_IMAP_HELP_URL in app/src/main/kotlin/org/libremail/ui/accountsetup/ImapDisabledPrompt.kt:46-48 — both files even carry comments promising the two 'point users to one page' (#411/#390), but that invariant is enforced only by comment, not by sharing the constant.
- severity: **low** · category: `reuse` · angle: `reuse`
> When Microsoft retires or moves the support article and someone updates the URL found by grep in ImapDisabledPrompt.kt, the onboarding pre-auth notice keeps linking the stale/dead page (or vice versa), breaking exactly the both-directions-one-page invariant the comments document. Fix: hoist one shared internal constant (e.g. in ImapDisabledPrompt.kt or MailProvider) and reference it from both sites.
### `app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt:53` — signatureCount and defaultSignatureName each stateIn the same cold Room flow (signatureRepository.observeForAccount), registering two identical Room query observers that both re-execute the query and re-map every row to domain on each signature-table change.
- severity: **low** · category: `efficiency` · angle: `efficiency`
> While the account-settings screen is open, every signature insert/update/delete (or any invalidation of the signatures table) runs the observeForAccount query twice and maps the result list twice — duplicated DB work for the screen's lifetime. Cheaper alternative: stateIn/shareIn the list once (e.g. a single StateFlow<List<Signature>>) and derive both count and default-name via .map off that shared hot flow.
### `app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt:115` — Account removal — deleting the account row, stored credential, all cached mail/folders/drafts, attachment cache, and the notification channel — produces no AppLog entry anywhere in the chain (neither here nor in AccountRepositoryImpl.deleteAccount), violating CLAUDE.md's Definition of done for 'lifecycle transitions ... significant state changes' (a PII-free log via accountLogRef(accountId) is the prescribed pattern, used by the corresponding account-ADD paths).
- severity: **low** · category: `conventions` · angle: `conventions`
> A user reports 'all my mail for an account vanished' (e.g. an accidental tap on Remove account, or a partially-failed deletion where credentialStore.delete succeeded but a later step threw mid-sequence); the debug report shows account-added breadcrumbs but no trace that a removal was ever initiated or how far it got, making the data-loss report impossible to reconstruct.
### `app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt:127` — setAppLock swallows the Keystore reseal exception with runCatching{...}.isSuccess and logs nothing on either the failure fallback (line 132 keeps app-lock on and shows a snackbar) or the enable/disable state change itself — violating CLAUDE.md's Definition of done ('error/fallback paths, significant state changes' must be logged; throwables passed to AppLog are auto-scrubbed, so the exception could safely be logged).
- severity: **low** · category: `conventions` · angle: `conventions`
> A user with an auth-sealed encrypted cache toggles app-lock off; databaseKeyStore.sealWithMaster() throws (e.g. KeyPermanentlyInvalidatedException after enrolling a new fingerprint); the user repeatedly sees 'app_lock_disable_failed' and files a debug report — the report contains no record of the reseal attempt, the thrown exception, or even that app-lock was being toggled, unlike the analogous paths in AppLockViewModel which log every seal/unseal failure. DatabaseKeyStore itself contains no AppLog calls, so nothing downstream covers it.
### `app/src/main/kotlin/org/libremail/ui/settings/SignaturesScreen.kt:113` — SignatureRow calls signature.plainText() (HtmlToText.convert HTML parsing) inline in composition with no remember, re-parsing each visible signature's HTML on every recomposition of the row.
- severity: **low** · category: `efficiency` · angle: `efficiency`
> On the signatures list, any state change that recomposes the rows (e.g. tapping the default radio, which flips isDefault on two rows and re-emits the whole list) re-runs the HTML-to-text conversion for every visible signature on the main thread; a long HTML signature makes each pass non-trivial. Cheaper alternative: remember(signature.html) { signature.plainText().replace('\n', ' ').trim() } so the parse runs once per content change.
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.
Source: whole-repo multi-agent review, 2026-07-09 (run
wf_b41de68c-e85). The run was cut short by usage limits before its verification pass, so every finding below is an unverified finder candidate — validate each against the current code before implementing. Findings are listed medium first, then low. Critical/high candidates from the same run were verified separately and have their own issues.Medium (1)
app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt:148— Tapping an already-selected retention radio (a no-op change) still runs the full pipeline: DataStore write, pruneNow() across all accounts, and resetBackfillProgress(null), which deletes every account's backfill-progress rows and schedules an immediate network backfill that re-pages folder history for all accounts.efficiency· angle:efficiencyLow (12)
app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt:88— The onFailure lambda in AppPasswordViewModel.testAndSave re-implements ManualSetupViewModel.onAddFailure (ManualSetupViewModel.kt:104-112) nearly verbatim: imapDisabledPromptFor(e, account, usedOAuth = false), the AppLog.i 'IMAP disabled ... prompting to enable IMAP' breadcrumb, the status=IDLE + imapDisabledPrompt update, and the identical generic fallback string 'Could not connect to the server'.reuse· angle:reuseapp/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt:97— Generic account-setup failure path has no AppLog breadcrumb: CLAUDE.md's Definition of done requires logging at 'error/fallback paths', but AppPasswordViewModel.testAndSave's onFailure else-branch (line 97) and ManualSetupViewModel.onAddFailure's else-branch (ManualSetupViewModel.kt line 110) log nothing, and the chain below (AccountRepositoryImpl.addImapAccount wraps everything in runCatching, logging only success at line 68) logs nothing either — only the narrow IMAP-disabled subcase is logged.conventions· angle:conventionsapp/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt:61— The paired LaunchedEffect blocks — status==DONE -> addedAccountId?.let(onAccountAdded), and error -> showSnackbar + consumeError — are copy-pasted four times: here (61-71), AppPasswordSetupScreen.kt:88-98, AccountPickerScreen.kt:94-104, and OutlookImapNoticeScreen.kt:88-98, all over the same SetupStatus/error/addedAccountId state shape.reuse· angle:reuseapp/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt:246— private fun MailSecurity.label() here is an identical copy of the private extension of the same name in AppPasswordSetupScreen.kt:291-295; the duplication exists only because both are file-private.reuse· angle:reuseapp/src/main/kotlin/org/libremail/ui/onboarding/BatteryGuideAnimation.kt:106— The infinite-transition values (tapAlpha every frame, phase) are read in composition and passed as plain parameters, so BatteryGuideAnimation, GuideCard, and the focused GuideRow recompose on every animation frame for as long as the onboarding battery screen is visible.efficiency· angle:efficiencyapp/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt:128— The READ_CONTACTS permission outcome is folded into state with no logging anywhere in the feature: onContactsPermissionResult (and the whole org.libremail.contacts package, which contains zero AppLog calls) records grant/denial silently, violating CLAUDE.md's Definition of done requirement that significant state changes be diagnosable from a debug report.conventions· angle:conventionsapp/src/main/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreen.kt:174— The Sign-in button's onClick duplicates AccountPickerScreen's launchOutlookInline lambda (AccountPickerScreen.kt:84-92) verbatim — outlookAuthIntent().fold with runCatching { outlookLauncher.launch(intent) } and onOutlookLaunchFailed on both failure paths — and the screen's openUrl helper (lines 101-104) likewise duplicates AppPasswordSetupScreen's openUrl (AppPasswordSetupScreen.kt:83-86) including the pre-resolved openFailedMessage pattern.reuse· angle:reuseapp/src/main/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreen.kt:206— IMAP_HELP_URL is a byte-for-byte copy of OUTLOOK_IMAP_HELP_URL in app/src/main/kotlin/org/libremail/ui/accountsetup/ImapDisabledPrompt.kt:46-48 — both files even carry comments promising the two 'point users to one page' (#411/#390), but that invariant is enforced only by comment, not by sharing the constant.reuse· angle:reuseapp/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt:53— signatureCount and defaultSignatureName each stateIn the same cold Room flow (signatureRepository.observeForAccount), registering two identical Room query observers that both re-execute the query and re-map every row to domain on each signature-table change.efficiency· angle:efficiencyapp/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt:115— Account removal — deleting the account row, stored credential, all cached mail/folders/drafts, attachment cache, and the notification channel — produces no AppLog entry anywhere in the chain (neither here nor in AccountRepositoryImpl.deleteAccount), violating CLAUDE.md's Definition of done for 'lifecycle transitions ... significant state changes' (a PII-free log via accountLogRef(accountId) is the prescribed pattern, used by the corresponding account-ADD paths).conventions· angle:conventionsapp/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt:127— setAppLock swallows the Keystore reseal exception with runCatching{...}.isSuccess and logs nothing on either the failure fallback (line 132 keeps app-lock on and shows a snackbar) or the enable/disable state change itself — violating CLAUDE.md's Definition of done ('error/fallback paths, significant state changes' must be logged; throwables passed to AppLog are auto-scrubbed, so the exception could safely be logged).conventions· angle:conventionsapp/src/main/kotlin/org/libremail/ui/settings/SignaturesScreen.kt:113— SignatureRow calls signature.plainText() (HtmlToText.convert HTML parsing) inline in composition with no remember, re-parsing each visible signature's HTML on every recomposition of the row.efficiency· angle:efficiency