feat(auth): detect "IMAP disabled" AUTHENTICATE failures and prompt the user to enable IMAP #430

Merged
JMR-dev merged 3 commits from feat-390-imap-disabled-detection into main 2026-07-08 05:43:40 +00:00
JMR-dev commented 2026-07-08 04:55:34 +00:00 (Migrated from github.com)

Closes #390. The reactive complement to the pre-auth Outlook IMAP notice (#411/#426).

Problem

When account setup obtains a valid credential but the IMAP AUTHENTICATE step is then rejected because IMAP access is switched off for the mailbox (the default on new personal outlook.com accounts; also possible on Gmail etc.), the user saw an opaque generic "authentication failed" with no hint that the real fix is an account-side IMAP toggle.

Approach

  • ImapAuthError.isImapDisabled(error, usedOAuth) (new, org.libremail.mail) classifies the failure on two deliberately-conservative signals:

    1. Explicit provider text in the exception (or its cause chain): IMAP ... disabled / not enabled / turned off, incl. Gmail's Your account is not enabled for IMAP use.
    2. OAuth inference: on the Outlook XOAUTH2 path, a valid-token AuthenticationFailedException -- outlook.office365.com returns only a generic AUTHENTICATE failed with no distinctive text, and the token was already accepted at exchange, so an AUTHENTICATE rejection here means IMAP is off, not a bad token.

    Ordinary wrong-password / expired-token / network failures are not matched, so they keep the existing generic error (verified, incl. a real GreenMail wrong-password rejection).

  • imapDisabledPromptFor(error, account, usedOAuth) turns a classified failure into a provider-aware prompt: brand via MailProvider.brandFor(account) (so even a manually-configured Gmail host is recognised), with the provider's enable-IMAP help URL for Outlook + Gmail and a generic message with no link otherwise.

UI surface chosen

A shared ImapDisabledDialog (Material 3 AlertDialog) rather than a dedicated screen: the reactive failure can surface from three entry points -- the Outlook picker (AccountSetupViewModel/AccountPickerScreen), the app-password form, and manual setup -- so a dialog overlaid on whichever screen the user is on is the consistent, low-plumbing choice (a full screen would need navigation wiring across all three). It shows sign-in-succeeded-but-IMAP-rejected copy, a "How to turn on IMAP" link (leading), and "Got it" (trailing, the stable E2E click target). All three view-models set an imapDisabledPrompt in state instead of the generic error when classified as disabled.

Provider signals keyed on

  • Outlook/Microsoft (primary, the on-device evidence in #390): valid XOAUTH2 token + generic AUTHENTICATE failed -> IMAP off; links Microsoft's POP/IMAP settings article.
  • Gmail: not enabled for IMAP use / enable ... IMAP text; links Google's IMAP settings article.
  • Yahoo/iCloud/AOL: recognised brand, generic message, no deep link (they gate via app passwords, not a user-facing IMAP switch).

Logging

PII-free AppLog breadcrumbs at each classification/prompt point using accountLogRef(id) only -- never the email/host/token. The dialog stays pure UI.

Tests

  • ImapAuthErrorTest -- provider "IMAP disabled" text mappings + Outlook OAuth inference + negatives (wrong password with/without OAuth, token-exchange failure, network error, bare IMAP host in message), plus a real GreenMail wrong-password rejection asserted not misclassified.
  • ImapDisabledPromptTest -- brand/URL resolution (Outlook, Gmail, manual-Gmail-host, Yahoo, unknown host, non-disabled).
  • ImapDisabledDialogJvmTest (Robolectric) -- branded vs generic message, help-link launch without dismiss, "Got it" dismiss.
  • Per-view-model IMAP-disabled tests (AccountSetupViewModelTest, AppPasswordViewModelTest, ManualSetupViewModelTest) + per-screen dialog-wiring tests (all three screen JVM tests).
  • Instrumented AppPasswordSetupScreenTest case driving the failure through a FakeAccountRepository end to end (dialog + Gmail help-link ACTION_VIEW via Espresso-Intents).

Local gate (all green)

assembleDebug + testDebugUnitTest + jacocoTestCoverageVerification (0.84 floor held) + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt. Emulator E2E left to CI's matrix.

Closes #390. The reactive complement to the pre-auth Outlook IMAP notice (#411/#426). ## Problem When account setup obtains a valid credential but the IMAP `AUTHENTICATE` step is then rejected because IMAP access is switched **off** for the mailbox (the default on new personal `outlook.com` accounts; also possible on Gmail etc.), the user saw an opaque generic "authentication failed" with no hint that the real fix is an account-side IMAP toggle. ## Approach - **`ImapAuthError.isImapDisabled(error, usedOAuth)`** (new, `org.libremail.mail`) classifies the failure on two deliberately-conservative signals: 1. **Explicit provider text** in the exception (or its cause chain): `IMAP ... disabled / not enabled / turned off`, incl. Gmail's `Your account is not enabled for IMAP use`. 2. **OAuth inference**: on the Outlook **XOAUTH2** path, a valid-token `AuthenticationFailedException` -- `outlook.office365.com` returns only a generic `AUTHENTICATE failed` with no distinctive text, and the token was already accepted at exchange, so an AUTHENTICATE rejection here means IMAP is off, not a bad token. Ordinary wrong-password / expired-token / network failures are **not** matched, so they keep the existing generic error (verified, incl. a real GreenMail wrong-password rejection). - **`imapDisabledPromptFor(error, account, usedOAuth)`** turns a classified failure into a provider-aware prompt: brand via `MailProvider.brandFor(account)` (so even a manually-configured Gmail host is recognised), with the provider's enable-IMAP help URL for Outlook + Gmail and a generic message with no link otherwise. ## UI surface chosen A shared **`ImapDisabledDialog`** (Material 3 `AlertDialog`) rather than a dedicated screen: the reactive failure can surface from three entry points -- the Outlook picker (`AccountSetupViewModel`/`AccountPickerScreen`), the app-password form, and manual setup -- so a dialog overlaid on whichever screen the user is on is the consistent, low-plumbing choice (a full screen would need navigation wiring across all three). It shows sign-in-succeeded-but-IMAP-rejected copy, a "How to turn on IMAP" link (leading), and "Got it" (trailing, the stable E2E click target). All three view-models set an `imapDisabledPrompt` in state instead of the generic `error` when classified as disabled. ## Provider signals keyed on - **Outlook/Microsoft** (primary, the on-device evidence in #390): valid XOAUTH2 token + generic `AUTHENTICATE failed` -> IMAP off; links Microsoft's POP/IMAP settings article. - **Gmail**: `not enabled for IMAP use` / `enable ... IMAP` text; links Google's IMAP settings article. - **Yahoo/iCloud/AOL**: recognised brand, generic message, no deep link (they gate via app passwords, not a user-facing IMAP switch). ## Logging PII-free `AppLog` breadcrumbs at each classification/prompt point using `accountLogRef(id)` only -- never the email/host/token. The dialog stays pure UI. ## Tests - `ImapAuthErrorTest` -- provider "IMAP disabled" text mappings + Outlook OAuth inference + negatives (wrong password with/without OAuth, token-exchange failure, network error, bare IMAP host in message), plus a real GreenMail wrong-password rejection asserted *not* misclassified. - `ImapDisabledPromptTest` -- brand/URL resolution (Outlook, Gmail, manual-Gmail-host, Yahoo, unknown host, non-disabled). - `ImapDisabledDialogJvmTest` (Robolectric) -- branded vs generic message, help-link launch without dismiss, "Got it" dismiss. - Per-view-model IMAP-disabled tests (`AccountSetupViewModelTest`, `AppPasswordViewModelTest`, `ManualSetupViewModelTest`) + per-screen dialog-wiring tests (all three screen JVM tests). - Instrumented `AppPasswordSetupScreenTest` case driving the failure through a `FakeAccountRepository` end to end (dialog + Gmail help-link `ACTION_VIEW` via Espresso-Intents). ## Local gate (all green) `assembleDebug` + `testDebugUnitTest` + `jacocoTestCoverageVerification` (0.84 floor held) + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` + `detekt`. Emulator E2E left to CI's matrix.
mergify[bot] commented 2026-07-08 05:23:48 +00:00 (Migrated from github.com)

Merge Queue Status

  • ✅ Entered queue — 2026-07-08 05:23 UTC · Rule: default · triggered by merge protections
  • ✅ Checks passed · in-place
  • ✅ Merged — 2026-07-08 05:43 UTC · at d6665fe8c379ebcdf415432f711aacac00c0f946 · merge

This pull request spent 19 minutes 55 seconds in the queue, including 19 minutes 44 seconds running CI.

Required conditions to merge
<!--- DO NOT EDIT -*- Mergify Payload -*- {"version": 1, "state": "merged", "queue_rule_name": "default", "queued_at": "2026-07-08T05:23:46.004411+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []} -*- Mergify Payload End -*- --> # Merge Queue Status - ✅ **Entered queue** — `2026-07-08 05:23 UTC` · Rule: `default` · triggered by merge protections - ✅ **Checks passed** · in-place - ✅ **Merged** — `2026-07-08 05:43 UTC` · at `d6665fe8c379ebcdf415432f711aacac00c0f946` · merge This pull request spent **19 minutes 55 seconds** in the queue, including **19 minutes 44 seconds** running CI. <details> <summary>Required conditions to merge</summary> - `-conflict` - [X] #430 - `-draft` - [X] #430 - [X] `base = main` - [X] `check-success = CI passed` - `github-review-approved` [🛡 GitHub repository ruleset rule `main`] - [X] #430 - `label != broken` - [X] #430 - [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.