From 60f822a63c7138295517c0ea9c82a2d1076e8846 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 7 Jul 2026 23:54:57 -0500 Subject: [PATCH] feat(auth): detect "IMAP disabled" AUTHENTICATE failures and prompt to enable IMAP When account setup obtains a valid credential but the IMAP AUTHENTICATE step is rejected because IMAP access is switched off for the mailbox, surface an actionable "turn on IMAP" dialog (with the provider's enable-IMAP help link) instead of the opaque generic auth error (#390). - ImapAuthError.isImapDisabled classifies the failure on two signals: explicit provider "IMAP is disabled/not enabled" server text (e.g. Gmail's "not enabled for IMAP use"), and -- for the Outlook XOAUTH2 path -- a valid-token AUTHENTICATE rejection, which outlook.office365.com reports only as a generic "AUTHENTICATE failed". Ordinary wrong-password / expired-token / network errors are deliberately not matched, so they keep the generic error. - imapDisabledPromptFor resolves a provider-aware prompt (brand via MailProvider.brandFor; Outlook + Gmail enable-IMAP help URLs, generic otherwise). - Shared ImapDisabledDialog reused by the Outlook picker, the app-password form, and manual setup -- the three points where the auth failure surfaces. The reactive complement to the pre-auth Outlook notice (#411/#426). - PII-free AppLog breadcrumbs at the classification/prompt points (accountLogRef only; never the email/host/token). Tests: ImapAuthErrorTest (provider text + OAuth inference + a real GreenMail wrong-password negative), ImapDisabledPromptTest (brand/URL resolution), ImapDisabledDialogJvmTest (Robolectric), per-view-model + per-screen wiring tests, and an instrumented AppPasswordSetupScreenTest case driving the failure end to end. Closes #390 --- .../AppPasswordSetupScreenTest.kt | 42 ++++ .../org/libremail/mail/ImapAuthError.kt | 77 +++++++ .../ui/accountsetup/AccountPickerScreen.kt | 6 + .../ui/accountsetup/AccountSetupViewModel.kt | 51 +++-- .../ui/accountsetup/AppPasswordSetupScreen.kt | 5 + .../ui/accountsetup/AppPasswordViewModel.kt | 32 ++- .../ui/accountsetup/ImapDisabledDialog.kt | 56 +++++ .../ui/accountsetup/ImapDisabledPrompt.kt | 52 +++++ .../ui/accountsetup/ManualSetupScreen.kt | 5 + .../ui/accountsetup/ManualSetupViewModel.kt | 37 +++- app/src/main/res/values/strings.xml | 8 + .../org/libremail/mail/ImapAuthErrorTest.kt | 194 ++++++++++++++++++ .../AccountPickerScreenJvmTest.kt | 13 ++ .../accountsetup/AccountSetupViewModelTest.kt | 49 +++++ .../AppPasswordSetupScreenJvmTest.kt | 17 ++ .../accountsetup/AppPasswordViewModelTest.kt | 43 +++- .../accountsetup/ImapDisabledDialogJvmTest.kt | 105 ++++++++++ .../ui/accountsetup/ImapDisabledPromptTest.kt | 113 ++++++++++ .../accountsetup/ManualSetupScreenJvmTest.kt | 14 ++ .../accountsetup/ManualSetupViewModelTest.kt | 42 +++- 20 files changed, 931 insertions(+), 30 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/mail/ImapAuthError.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/accountsetup/ImapDisabledDialog.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/accountsetup/ImapDisabledPrompt.kt create mode 100644 app/src/test/kotlin/org/libremail/mail/ImapAuthErrorTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/accountsetup/ImapDisabledDialogJvmTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/accountsetup/ImapDisabledPromptTest.kt diff --git a/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenTest.kt index 4118efd..42a53fc 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenTest.kt @@ -7,6 +7,7 @@ import android.content.Intent import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo @@ -15,8 +16,11 @@ import androidx.lifecycle.SavedStateHandle import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasData +import androidx.test.espresso.intent.matcher.UriMatchers.hasHost import androidx.test.ext.junit.runners.AndroidJUnit4 +import jakarta.mail.AuthenticationFailedException import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.CoreMatchers.equalTo import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -108,4 +112,42 @@ class AppPasswordSetupScreenTest { Intents.release() } } + + /** + * When the connection test fails specifically because IMAP is disabled (Gmail's "not enabled for + * IMAP use"), the screen surfaces the actionable "turn on IMAP" dialog instead of a generic error, + * and its help link opens the provider's enable-IMAP page (#390). Driving the failure through a + * [FakeAccountRepository] exercises the real classification + dialog wiring end to end on device. + */ + @Test + fun imapDisabledFailure_showsThePrompt_andHelpLinkOpensTheProviderPage() { + setContent( + repository = FakeAccountRepository( + result = Result.failure( + AuthenticationFailedException("Your account is not enabled for IMAP use"), + ), + ), + ) + + composeTestRule.onNodeWithText(string(R.string.app_password_email)).performTextInput("me@gmail.com") + composeTestRule.onNodeWithText(string(R.string.app_password_field)).performTextInput("app-pw-1234") + composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)).performScrollTo().performClick() + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText(string(R.string.imap_disabled_title)).fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithText(string(R.string.imap_disabled_message, provider.displayName)).assertIsDisplayed() + + Intents.init() + try { + Intents.intending(hasAction(Intent.ACTION_VIEW)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)) + + composeTestRule.onNodeWithText(string(R.string.imap_disabled_help)).performClick() + + Intents.intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(hasHost(equalTo("support.google.com"))))) + } finally { + Intents.release() + } + } } diff --git a/app/src/main/kotlin/org/libremail/mail/ImapAuthError.kt b/app/src/main/kotlin/org/libremail/mail/ImapAuthError.kt new file mode 100644 index 0000000..7c820b5 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/ImapAuthError.kt @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import jakarta.mail.AuthenticationFailedException + +/** + * Classifies an IMAP authentication failure that surfaced from [ImapClient.openConnectedStore]'s + * `store.connect` (i.e. an `AUTHENTICATE`/`LOGIN` rejection) into the one distinction the account-setup + * UI cares about: **"IMAP access is switched off for this account"** vs any other auth failure (a wrong + * password, an expired/invalid token, a network error, …). Only the former can be fixed by the user + * flipping a provider-side toggle, so only it earns the actionable "turn on IMAP" prompt (issue #390); + * everything else keeps the existing generic error. + * + * The heuristic is deliberately conservative — misclassifying a wrong password as "IMAP disabled" would + * send the user down a dead end — so it fires on only two well-motivated signals (see [isImapDisabled]). + */ +object ImapAuthError { + + /** + * True when [error] (or anything in its cause chain) indicates the account's IMAP access is + * **disabled/not enabled**, rather than a wrong credential or other failure. Two signals, checked + * in order: + * + * 1. **Explicit server text.** The failure message names IMAP as disabled/not-enabled/turned-off — + * e.g. Gmail's `Your account is not enabled for IMAP use`, or a `IMAP access is disabled` + * variant. This is provider-independent and works regardless of [usedOAuth]. + * 2. **OAuth inference.** When the connection authenticated with a **freshly obtained XOAUTH2 + * token** ([usedOAuth] true) and the server still raised an [AuthenticationFailedException], the + * token itself was accepted at consent/exchange time, so an `AUTHENTICATE` rejection here almost + * always means IMAP is off for the mailbox — not a bad token. This is the Outlook case that + * motivated #390: `outlook.office365.com` returns only a generic `AUTHENTICATE failed` with no + * distinctive text, so the text check in (1) cannot catch it. + * + * A password/app-password failure ([usedOAuth] false) with no IMAP-disabled text — the ordinary + * wrong-password case — is deliberately **not** matched, so it is never misclassified. + */ + fun isImapDisabled(error: Throwable, usedOAuth: Boolean): Boolean { + val chain = causeChain(error) + if (chain.any { it.message?.let(::mentionsImapDisabled) == true }) return true + return usedOAuth && chain.any { it is AuthenticationFailedException } + } + + /** + * The exception and its transitive causes, in order, guarding against a self-referential or cyclic + * cause chain (identity-based visited check — [Throwable] does not override `equals`). + */ + private fun causeChain(error: Throwable): List { + val seen = mutableListOf() + var current: Throwable? = error + while (current != null && seen.none { it === current }) { + seen.add(current) + current = current.cause + } + return seen + } + + /** True when [message] names IMAP as disabled/not-enabled, in any of the shapes providers use. */ + private fun mentionsImapDisabled(message: String): Boolean { + val text = message.lowercase() + return DISABLED_PATTERNS.any { it.containsMatchIn(text) } + } + + /** + * Case-insensitive shapes of "IMAP is off" seen across providers (patterns run against a lowercased + * message). Kept broad enough to catch wording variants, but each anchors on both "imap" and an + * explicit off/disabled/not-enabled word so an ordinary "AUTHENTICATE failed" / "Invalid + * credentials" wrong-password message never matches. + */ + private val DISABLED_PATTERNS = listOf( + // "IMAP access is disabled", "IMAP is disabled", "IMAP access is not enabled", "IMAP ... turned off". + Regex("""imap[^\n]{0,40}?(disabled|not enabled|turned off|is off)"""), + // Reversed order, e.g. Gmail's "Your account is not enabled for IMAP use". + Regex("""(disabled|not enabled|turned off)[^\n]{0,20}?for imap"""), + // "please enable IMAP", Gmail's "enable your account for IMAP access". + Regex("""enable[^\n]{0,30}?imap"""), + ) +} diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt index ce3dd1c..7e0d0f9 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt @@ -160,6 +160,12 @@ fun AccountPickerScreen( CircularProgressIndicator() } } + // Outlook OAuth can succeed while the IMAP AUTHENTICATE step is rejected because IMAP is + // off for the mailbox (#390); show the actionable "turn on IMAP" prompt instead of a + // generic auth-failure snackbar. + state.imapDisabledPrompt?.let { prompt -> + ImapDisabledDialog(prompt = prompt, onDismiss = viewModel::dismissImapDisabledPrompt) + } } } } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt index 1c975fc..48e63f8 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt @@ -15,6 +15,7 @@ import org.libremail.auth.OutlookAuthManager import org.libremail.domain.model.Account import org.libremail.domain.repository.AccountRepository import org.libremail.reporting.AppLog +import org.libremail.reporting.accountLogRef import javax.inject.Inject /** Stage of an account-setup attempt, shared by the Outlook and manual flows. */ @@ -25,6 +26,11 @@ data class AccountSetupUiState( val error: String? = null, /** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */ val addedAccountId: String? = null, + /** + * Set instead of [error] when the failure was specifically an "IMAP is disabled" rejection (#390): + * the screen shows the actionable [ImapDisabledDialog] rather than the generic error snackbar. + */ + val imapDisabledPrompt: ImapDisabledPrompt? = null, ) @HiltViewModel @@ -62,34 +68,53 @@ class AccountSetupViewModel @Inject constructor( } viewModelScope.launch { _state.update { it.copy(status = SetupStatus.CONNECTING, error = null) } + // Captured so the failure branch can tell a token/consent failure (account still null) from + // a token-OK-but-IMAP-AUTHENTICATE-rejected one (account set), which is the #390 signal. + var account: Account? = null runCatching { val oauth = outlookAuthManager.exchangeToken(data) + val acct = Account.outlook(oauth.email) + account = acct accountRepository.addOutlookAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow() - Account.outlook(oauth.email).id + acct.id }.fold( onSuccess = { accountId -> // No email: the account id embeds it (see accountLogRef) and must never be logged. AppLog.i(TAG, "Outlook account added") _state.update { it.copy(status = SetupStatus.DONE, addedAccountId = accountId) } }, - onFailure = { e -> - // AppLog.d's Logcat mirror is stripped from release builds by the -assumenosideeffects - // Log.d ProGuard rule (keeps any account address / token detail out of shipped - // logcat), but that rule only elides the `Log.d(...)` call inside AppLog.d — the - // buffer.record(...) line right after it is untouched, so this breadcrumb still - // reaches a submitted report. The throwable's message may carry the account - // email/token; AppLog's StackTraceScrubber redacts it before it is recorded. - AppLog.d(TAG, "Outlook sign-in failed after redirect", e) - _state.update { - it.copy(status = SetupStatus.IDLE, error = e.message ?: "Microsoft sign-in failed") - } - }, + onFailure = { e -> onOutlookFailure(e, account) }, ) } } + /** + * Routes a failed Outlook add. When the OAuth token was obtained ([account] set) but the IMAP + * `AUTHENTICATE` step was rejected because IMAP is disabled, surfaces the actionable "turn on IMAP" + * prompt (#390); otherwise (token/consent failure, or any other error) keeps the generic message. + */ + private fun onOutlookFailure(e: Throwable, account: Account?) { + // AppLog.d's Logcat mirror is stripped from release builds by the -assumenosideeffects + // Log.d ProGuard rule (keeps any account address / token detail out of shipped logcat), but + // that rule only elides the `Log.d(...)` call inside AppLog.d — the buffer.record(...) line + // right after it is untouched, so this breadcrumb still reaches a submitted report. The + // throwable's message may carry the account email/token; AppLog's StackTraceScrubber redacts + // it before it is recorded. + AppLog.d(TAG, "Outlook sign-in failed after redirect", e) + val prompt = account?.let { imapDisabledPromptFor(e, it, usedOAuth = true) } + if (prompt != null && account != null) { + AppLog.i(TAG, "IMAP disabled on Outlook sign-in (${accountLogRef(account.id)}); prompting to enable IMAP") + _state.update { it.copy(status = SetupStatus.IDLE, imapDisabledPrompt = prompt, error = null) } + } else { + _state.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Microsoft sign-in failed") } + } + } + fun consumeError() = _state.update { it.copy(error = null) } + /** Clears the "IMAP is disabled" prompt after the user acknowledges it (issue #390). */ + fun dismissImapDisabledPrompt() = _state.update { it.copy(imapDisabledPrompt = null) } + private companion object { const val TAG = "AccountSetupVM" } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt index 82ec211..c36cbbc 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt @@ -214,6 +214,11 @@ fun AppPasswordSetupScreen( Text(stringResource(R.string.app_password_test_and_add)) } } + // A wrong app password and "IMAP is turned off" both fail auth, but only the latter is fixed by + // a provider toggle (e.g. Gmail's Enable IMAP); surface it as an actionable prompt (#390). + form.imapDisabledPrompt?.let { prompt -> + ImapDisabledDialog(prompt = prompt, onDismiss = viewModel::dismissImapDisabledPrompt) + } } } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt index 9964c0a..e63a671 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt @@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.libremail.domain.model.MailProvider import org.libremail.domain.repository.AccountRepository +import org.libremail.reporting.AppLog +import org.libremail.reporting.accountLogRef import org.libremail.ui.navigation.Routes import javax.inject.Inject @@ -23,6 +25,11 @@ data class AppPasswordForm( val error: String? = null, /** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */ val addedAccountId: String? = null, + /** + * Set instead of [error] when the failure was specifically an "IMAP is disabled" rejection (#390): + * the screen shows the actionable [ImapDisabledDialog] rather than the generic error snackbar. + */ + val imapDisabledPrompt: ImapDisabledPrompt? = null, ) { val isValid: Boolean get() = email.isNotBlank() && appPassword.isNotBlank() } @@ -53,6 +60,9 @@ class AppPasswordViewModel @Inject constructor( fun toggleAdvanced() = _form.update { it.copy(advancedExpanded = !it.advancedExpanded) } fun consumeError() = _form.update { it.copy(error = null) } + /** Clears the "IMAP is disabled" prompt after the user acknowledges it (issue #390). */ + fun dismissImapDisabledPrompt() = _form.update { it.copy(imapDisabledPrompt = null) } + fun testAndSave() { val provider = provider if (provider == null) { @@ -72,14 +82,28 @@ class AppPasswordViewModel @Inject constructor( _form.update { it.copy(status = SetupStatus.DONE, addedAccountId = account.id) } }, onFailure = { e -> - _form.update { - it.copy( - status = SetupStatus.IDLE, - error = e.message ?: "Could not connect to the server", + // App passwords are never XOAUTH2, so IMAP-disabled is inferred only from the + // server's message text (e.g. Gmail's "not enabled for IMAP use"), not a token + // signal (#390). + val prompt = imapDisabledPromptFor(e, account, usedOAuth = false) + if (prompt != null) { + AppLog.i( + TAG, + "IMAP disabled on app-password setup (${accountLogRef(account.id)}); " + + "prompting to enable IMAP", ) + _form.update { it.copy(status = SetupStatus.IDLE, imapDisabledPrompt = prompt, error = null) } + } else { + _form.update { + it.copy(status = SetupStatus.IDLE, error = e.message ?: "Could not connect to the server") + } } }, ) } } + + private companion object { + const val TAG = "AppPasswordVM" + } } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ImapDisabledDialog.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ImapDisabledDialog.kt new file mode 100644 index 0000000..c3f0d13 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ImapDisabledDialog.kt @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import org.libremail.R + +/** + * Actionable dialog shown when adding an account fails specifically because IMAP is switched off for it + * (issue #390) — the reactive complement to the pre-auth Outlook notice (#411). Instead of the generic + * "authentication failed" snackbar, it explains that sign-in succeeded but the server rejected IMAP, + * and (when [ImapDisabledPrompt.helpUrl] is known) links the provider's page for turning IMAP on. + * + * A shared surface reused by every setup screen (the Outlook picker, the app-password form, manual + * setup) so the message and link stay consistent wherever the failure occurs. The help link is placed + * as the dialog's *dismiss* action (leading) and "Got it" as the *confirm* action (trailing), so the + * confirm button — the last control — stays the stable click target for E2E. + * + * @param prompt the provider brand + help URL to render. + * @param onDismiss clears the prompt from state (also fired on outside-tap / back). + */ +@Composable +fun ImapDisabledDialog(prompt: ImapDisabledPrompt, onDismiss: () -> Unit) { + val uriHandler = LocalUriHandler.current + val helpUrl = prompt.helpUrl + val message = prompt.brand?.let { stringResource(R.string.imap_disabled_message, it) } + ?: stringResource(R.string.imap_disabled_message_generic) + + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Filled.Email, contentDescription = null) }, + title = { Text(stringResource(R.string.imap_disabled_title)) }, + text = { Text(message) }, + // The help link is only offered for providers with a known enable-IMAP page. openUri throws + // when no browser is installed; swallow it (a rare case) — the primary "Got it" action, which + // dismisses so the user can fix the toggle and retry, always works. Opening the link + // deliberately leaves the dialog up so it is still there when the user returns from the browser. + dismissButton = { + if (helpUrl != null) { + TextButton(onClick = { runCatching { uriHandler.openUri(helpUrl) } }) { + Text(stringResource(R.string.imap_disabled_help)) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.imap_disabled_dismiss)) } + }, + ) +} diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ImapDisabledPrompt.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ImapDisabledPrompt.kt new file mode 100644 index 0000000..07b2719 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ImapDisabledPrompt.kt @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import org.libremail.domain.model.Account +import org.libremail.domain.model.MailProvider +import org.libremail.mail.ImapAuthError + +/** + * The data an "IMAP is turned off" prompt needs (issue #390): the provider [brand] for the message + * copy (null when the host maps to no known brand — a generic message is shown), and the [helpUrl] to + * the provider's enable-IMAP page (null when we have no provider-specific page, so no help link is + * offered). Built by [imapDisabledPromptFor] from a classified auth failure. + */ +data class ImapDisabledPrompt(val brand: String?, val helpUrl: String?) + +/** + * Returns the actionable prompt to show when [error] — the failure from adding [account] — is an + * "IMAP access is disabled" auth rejection ([ImapAuthError.isImapDisabled]), or null for any other + * failure (which keeps the existing generic error). [usedOAuth] is true only on the Outlook XOAUTH2 + * path, where a valid-token `AUTHENTICATE` rejection is itself the IMAP-disabled signal. + * + * Provider awareness comes from [account]: [MailProvider.brandFor] names the brand (Outlook by auth + * type/host, the app-password vendors by IMAP host — so even a manually-configured Gmail account is + * recognised), and [enableImapHelpUrl] maps it to the provider's help page. + */ +fun imapDisabledPromptFor(error: Throwable, account: Account, usedOAuth: Boolean): ImapDisabledPrompt? { + if (!ImapAuthError.isImapDisabled(error, usedOAuth)) return null + val brand = MailProvider.brandFor(account) + return ImapDisabledPrompt(brand = brand, helpUrl = enableImapHelpUrl(brand)) +} + +/** + * The provider's own "how to turn IMAP on" help page for a recognised [brand], or null for brands with + * no user-facing IMAP toggle we can link (Yahoo/iCloud/AOL gate access through app passwords, not an + * IMAP switch) or an unrecognised host — those get the generic message with no link. + */ +private fun enableImapHelpUrl(brand: String?): String? = when (brand) { + MailProvider.OUTLOOK_BRAND -> OUTLOOK_IMAP_HELP_URL + MailProvider.GMAIL.displayName -> GMAIL_IMAP_HELP_URL + else -> null +} + +// Microsoft's canonical "POP, IMAP, and SMTP settings for Outlook.com" support article — the +// authoritative walkthrough for the "Let devices and apps use POP/IMAP" toggle (verified 2026-07, +// issue #390). Matches the pre-auth notice in #411 so both directions point users to one page. +private const val OUTLOOK_IMAP_HELP_URL = + "https://support.microsoft.com/en-us/office/pop-imap-and-smtp-settings-for-outlook-com-" + + "d088b986-291d-42b8-9564-9c414e2aa040" + +// Google's "Check Gmail through other email platforms" article, which documents Settings -> See all +// settings -> Forwarding and POP/IMAP -> Enable IMAP (verified 2026-07, issue #390). +private const val GMAIL_IMAP_HELP_URL = "https://support.google.com/mail/answer/7126229" diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt index 81bb451..a9752c2 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt @@ -185,6 +185,11 @@ fun ManualSetupScreen( Text(stringResource(R.string.manual_test_and_add)) } } + // "IMAP is turned off" is one of the auth failures a manual account can hit; when the host maps + // to a known brand it even links that provider's enable-IMAP page (#390). + form.imapDisabledPrompt?.let { prompt -> + ImapDisabledDialog(prompt = prompt, onDismiss = viewModel::dismissImapDisabledPrompt) + } } } diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt index b27128a..640bda4 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt @@ -15,6 +15,8 @@ import org.libremail.domain.model.MailSecurity import org.libremail.domain.model.ServerConfig import org.libremail.domain.model.normalizeEmailForAccountId import org.libremail.domain.repository.AccountRepository +import org.libremail.reporting.AppLog +import org.libremail.reporting.accountLogRef import javax.inject.Inject data class ManualSetupForm( @@ -31,6 +33,11 @@ data class ManualSetupForm( val error: String? = null, /** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */ val addedAccountId: String? = null, + /** + * Set instead of [error] when the failure was specifically an "IMAP is disabled" rejection (#390): + * the screen shows the actionable [ImapDisabledDialog] rather than the generic error snackbar. + */ + val imapDisabledPrompt: ImapDisabledPrompt? = null, ) { val isValid: Boolean get() = email.isNotBlank() && password.isNotBlank() && imapHost.isNotBlank() && smtpHost.isNotBlank() @@ -41,6 +48,7 @@ class ManualSetupViewModel @Inject constructor(private val accountRepository: Ac private companion object { const val MAX_PORT_DIGITS = 5 + const val TAG = "ManualSetupVM" } private val _form = MutableStateFlow(ManualSetupForm()) @@ -59,6 +67,9 @@ class ManualSetupViewModel @Inject constructor(private val accountRepository: Ac fun toggleAdvanced() = _form.update { it.copy(advancedExpanded = !it.advancedExpanded) } fun consumeError() = _form.update { it.copy(error = null) } + /** Clears the "IMAP is disabled" prompt after the user acknowledges it (issue #390). */ + fun dismissImapDisabledPrompt() = _form.update { it.copy(imapDisabledPrompt = null) } + fun testAndSave() { val f = _form.value if (!f.isValid) { @@ -79,16 +90,24 @@ class ManualSetupViewModel @Inject constructor(private val accountRepository: Ac _form.update { it.copy(status = SetupStatus.CONNECTING, error = null) } accountRepository.addImapAccount(account, f.password).fold( onSuccess = { _form.update { it.copy(status = SetupStatus.DONE, addedAccountId = account.id) } }, - onFailure = { e -> - _form.update { - it.copy( - status = SetupStatus.IDLE, - error = - e.message ?: "Could not connect to the server", - ) - } - }, + onFailure = { e -> onAddFailure(e, account) }, ) } } + + /** + * Routes a failed manual add: an "IMAP is disabled" rejection (recognised by server text; #390) + * gets the actionable prompt — provider-aware when the host maps to a known brand, e.g. a + * manually-configured Gmail account still links Gmail's enable-IMAP page — otherwise the generic + * error is kept. + */ + private fun onAddFailure(e: Throwable, account: Account) { + val prompt = imapDisabledPromptFor(e, account, usedOAuth = false) + if (prompt != null) { + AppLog.i(TAG, "IMAP disabled on manual setup (${accountLogRef(account.id)}); prompting to enable IMAP") + _form.update { it.copy(status = SetupStatus.IDLE, imapDisabledPrompt = prompt, error = null) } + } else { + _form.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Could not connect to the server") } + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f02be49..e7d7d5b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -205,6 +205,14 @@ Other (IMAP/SMTP) Choose your email provider to get started. + + Turn on IMAP to continue + You signed in successfully, but %1$s rejected IMAP access for this account. Make sure IMAP is turned on in your account settings, then try again. + You signed in successfully, but the mail server rejected IMAP access for this account. Make sure IMAP is turned on in your account settings, then try again. + How to turn on IMAP + Got it + Connect %1$s Unknown email provider. diff --git a/app/src/test/kotlin/org/libremail/mail/ImapAuthErrorTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapAuthErrorTest.kt new file mode 100644 index 0000000..efc5e51 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/ImapAuthErrorTest.kt @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import com.icegreen.greenmail.util.GreenMail +import com.icegreen.greenmail.util.ServerSetupTest +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import jakarta.mail.AuthenticationFailedException +import jakarta.mail.MessagingException +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.domain.model.ImapConnectionParams +import org.libremail.domain.model.MailSecurity +import java.io.IOException +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Unit tests for the #390 error classifier: representative provider "IMAP is disabled" `AUTHENTICATE` + * rejections map to the distinct IMAP-disabled outcome, while ordinary wrong-password / expired-token / + * network failures do not (so the actionable "turn on IMAP" prompt never hijacks a real credential + * error). The provider-text cases use constructed exceptions carrying the real server wording; the + * "wrong password is not misclassified" case is proven end to end against a real GreenMail IMAP server. + */ +class ImapAuthErrorTest { + + // --- Provider "IMAP disabled" server text (works regardless of auth mechanism) --- + + @Test + fun `Gmail not-enabled-for-IMAP text is classified as disabled`() { + // Gmail's verbatim rejection when IMAP is off in its settings. + val error = AuthenticationFailedException( + "[ALERT] Your account is not enabled for IMAP use. Please visit your Gmail settings page " + + "and enable your account for IMAP access. (Failure)", + ) + assertTrue(ImapAuthError.isImapDisabled(error, usedOAuth = false)) + } + + @Test + fun `explicit IMAP access is disabled text is classified as disabled`() { + assertTrue( + ImapAuthError.isImapDisabled( + AuthenticationFailedException("IMAP access is disabled for your account."), + usedOAuth = false, + ), + ) + } + + @Test + fun `IMAP is disabled text is classified as disabled`() { + assertTrue(ImapAuthError.isImapDisabled(MessagingException("IMAP is disabled"), usedOAuth = false)) + } + + @Test + fun `IMAP access not enabled text is classified as disabled`() { + assertTrue( + ImapAuthError.isImapDisabled( + AuthenticationFailedException("IMAP access is not enabled for this account"), + usedOAuth = false, + ), + ) + } + + @Test + fun `please enable IMAP text is classified as disabled`() { + assertTrue( + ImapAuthError.isImapDisabled( + AuthenticationFailedException("Login failed. Please enable IMAP for your mailbox."), + usedOAuth = false, + ), + ) + } + + @Test + fun `disabled text in a wrapped cause is still classified as disabled`() { + val wrapped = RuntimeException("Adding account failed", AuthenticationFailedException("IMAP is disabled")) + assertTrue(ImapAuthError.isImapDisabled(wrapped, usedOAuth = false)) + } + + // --- Outlook OAuth inference: valid token + generic AUTHENTICATE rejection == IMAP off --- + + @Test + fun `a generic AUTHENTICATE failure on the XOAUTH2 path is inferred as disabled`() { + // outlook.office365.com returns only "AUTHENTICATE failed" with no distinctive text; the fresh + // token was already accepted at exchange, so this means IMAP is off (issue #390). + val error = AuthenticationFailedException("AUTHENTICATE failed") + assertTrue(ImapAuthError.isImapDisabled(error, usedOAuth = true)) + } + + @Test + fun `a wrapped AUTHENTICATE failure on the XOAUTH2 path is inferred as disabled`() { + val wrapped = MessagingException("connect failed", AuthenticationFailedException("AUTHENTICATE failed")) + assertTrue(ImapAuthError.isImapDisabled(wrapped, usedOAuth = true)) + } + + // --- Negatives: ordinary auth/other failures must NOT be misclassified --- + + @Test + fun `a generic AUTHENTICATE failure without OAuth is not classified as disabled`() { + // The password/app-password path: a plain "AUTHENTICATE failed" is a wrong password, not + // IMAP-off — misclassifying it would send the user to the wrong fix. + val error = AuthenticationFailedException("AUTHENTICATE failed") + assertFalse(ImapAuthError.isImapDisabled(error, usedOAuth = false)) + } + + @Test + fun `an invalid-credentials failure is not classified as disabled`() { + assertFalse( + ImapAuthError.isImapDisabled( + AuthenticationFailedException("[AUTHENTICATIONFAILED] Invalid credentials (Failure)"), + usedOAuth = false, + ), + ) + // Even on the OAuth path, an explicit invalid-credentials message is a token problem, not IMAP. + assertFalse( + ImapAuthError.isImapDisabled( + MessagingException("Invalid credentials, please re-authenticate"), + usedOAuth = true, + ), + ) + } + + @Test + fun `a token-exchange failure on the OAuth path is not classified as disabled`() { + // A token/consent failure is not a jakarta.mail AuthenticationFailedException, so the inference + // must not fire even with usedOAuth = true (the fix there is re-auth, not enabling IMAP). + assertFalse(ImapAuthError.isImapDisabled(IllegalStateException("Token exchange failed"), usedOAuth = true)) + } + + @Test + fun `a plain AUTHENTICATE-failed string that is not an auth exception is not inferred`() { + // The OAuth inference keys on the AuthenticationFailedException type, not the words + // "AUTHENTICATE failed" appearing in some other exception's message. + assertFalse(ImapAuthError.isImapDisabled(IOException("AUTHENTICATE failed"), usedOAuth = true)) + } + + @Test + fun `a network failure on the OAuth path is not classified as disabled`() { + assertFalse(ImapAuthError.isImapDisabled(IOException("Connection reset"), usedOAuth = true)) + } + + @Test + fun `an IMAP host name in the message alone is not classified as disabled`() { + // "imap.gmail.com" contains "imap" but no disabled/off wording, so it must not match. + assertFalse( + ImapAuthError.isImapDisabled( + AuthenticationFailedException("login to imap.gmail.com failed"), + usedOAuth = false, + ), + ) + } + + // --- End-to-end: a real GreenMail wrong-password rejection is not misclassified --- + + private lateinit var greenMail: GreenMail + private val client = ImapClient(reuseConnections = false) + + @Before + fun setUp() { + greenMail = GreenMail(ServerSetupTest.SMTP_IMAP) + greenMail.start() + greenMail.setUser("alice@example.org", "secret") + // listFolders breadcrumbs via AppLog -> android.util.Log, a throwing stub under plain JVM tests; + // mock it fully-qualified so this file never imports android.util.Log (detekt ForbiddenImport). + mockkStatic(android.util.Log::class) + every { android.util.Log.d(any(), any()) } returns 0 + every { android.util.Log.i(any(), any()) } returns 0 + every { android.util.Log.w(any(), any()) } returns 0 + } + + @After + fun tearDown() { + greenMail.stop() + unmockkAll() + } + + @Test + fun `a real wrong-password IMAP rejection is not classified as disabled`() = runTest { + val params = ImapConnectionParams( + host = "127.0.0.1", + port = greenMail.imap.port, + security = MailSecurity.NONE, + username = "alice@example.org", + secret = "wrong-password", + useXoauth2 = false, + ) + val error = runCatching { client.listFolders(params) }.exceptionOrNull() + assertTrue(error != null, "GreenMail should reject the wrong password") + assertFalse(ImapAuthError.isImapDisabled(error, usedOAuth = false)) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenJvmTest.kt index 33b1557..aa1198a 100644 --- a/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenJvmTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenJvmTest.kt @@ -213,4 +213,17 @@ class AccountPickerScreenJvmTest { composeTestRule.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed() } + + @Test + fun imapDisabledPrompt_showsTheDialog_andGotItDismisses() { + // Outlook OAuth can succeed while IMAP is off, surfacing the actionable dialog (#390) instead + // of the generic error snackbar; "Got it" clears the prompt via the view-model. + val vm = viewModel(AccountSetupUiState(imapDisabledPrompt = ImapDisabledPrompt("Outlook", helpUrl = null))) + setContent(vm) + + composeTestRule.onNodeWithText(string(R.string.imap_disabled_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.imap_disabled_dismiss)).performClick() + + verify { vm.dismissImapDisabledPrompt() } + } } diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModelTest.kt index 0033404..332bf07 100644 --- a/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModelTest.kt @@ -10,6 +10,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll +import jakarta.mail.AuthenticationFailedException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -191,6 +192,54 @@ class AccountSetupViewModelTest { assertEquals("IMAP verification failed", vm.state.value.error) } + @Test + fun `a token-OK but IMAP-disabled AUTHENTICATE failure surfaces the enable-IMAP prompt`() = runTest(dispatcher) { + // OAuth succeeds, then the IMAP AUTHENTICATE step is rejected (IMAP off for the mailbox): + // the actionable prompt replaces the generic error, and no account is marked added (#390). + val manager = mockk(relaxed = true) + coEvery { manager.exchangeToken(any()) } returns + OAuthResult(email = "me@outlook.com", accessToken = "tok", authStateJson = "{}") + val accounts = mockk(relaxed = true) + coEvery { accounts.addOutlookAccount(any(), any(), any()) } returns + Result.failure(AuthenticationFailedException("AUTHENTICATE failed")) + val vm = viewModel(outlookAuthManager = manager, accountRepository = accounts) + + vm.onOutlookResult(mockk(relaxed = true)) + advanceUntilIdle() + + val prompt = vm.state.value.imapDisabledPrompt + assertEquals("Outlook", prompt?.brand) + assertTrue(prompt?.helpUrl?.startsWith("https://support.microsoft.com/") == true) + assertEquals(SetupStatus.IDLE, vm.state.value.status) + assertNull(vm.state.value.error) + assertNull(vm.state.value.addedAccountId) + // PII-free breadcrumb: the account is referenced by its hashed log ref, never the email. + val disabledLine = logBuffer.snapshot().single { it.message.contains("IMAP disabled on Outlook sign-in") } + assertEquals('I', disabledLine.level) + assertFalse(disabledLine.message.contains("@"), disabledLine.message) + + vm.dismissImapDisabledPrompt() + assertNull(vm.state.value.imapDisabledPrompt) + } + + @Test + fun `a wrong-password style AUTHENTICATE failure keeps the generic error, not the prompt`() = runTest(dispatcher) { + // A plain OAuth-path failure with no IMAP-disabled signal stays a generic error. + val manager = mockk(relaxed = true) + coEvery { manager.exchangeToken(any()) } returns + OAuthResult(email = "me@outlook.com", accessToken = "tok", authStateJson = "{}") + val accounts = mockk(relaxed = true) + coEvery { accounts.addOutlookAccount(any(), any(), any()) } returns + Result.failure(RuntimeException("Could not reach the server")) + val vm = viewModel(outlookAuthManager = manager, accountRepository = accounts) + + vm.onOutlookResult(mockk(relaxed = true)) + advanceUntilIdle() + + assertNull(vm.state.value.imapDisabledPrompt) + assertEquals("Could not reach the server", vm.state.value.error) + } + @Test fun `a failure whose message carries the account email is scrubbed before it reaches the buffer`() = runTest(dispatcher) { diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenJvmTest.kt index d83c866..c8e8b7b 100644 --- a/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenJvmTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenJvmTest.kt @@ -232,4 +232,21 @@ class AppPasswordSetupScreenJvmTest { assertTrue(openedUrls.contains(MailProvider.GMAIL.appPasswordHelpUrl)) assertTrue(openedUrls.contains(MailProvider.GMAIL.twoFactorHelpUrl)) } + + @Test + fun imapDisabledPrompt_showsTheDialog_andGotItDismisses() { + // A wrong app password and "IMAP is off" both fail auth; the latter surfaces the actionable + // dialog (#390) instead of the generic snackbar, and "Got it" clears it via the view-model. + val vm = viewModel( + MailProvider.GMAIL, + AppPasswordForm(imapDisabledPrompt = ImapDisabledPrompt(brand = "Gmail", helpUrl = null)), + ) + setContent(vm) + + composeTestRule.onNodeWithText(string(R.string.imap_disabled_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.imap_disabled_message, "Gmail")).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.imap_disabled_dismiss)).performClick() + + verify { vm.dismissImapDisabledPrompt() } + } } diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt index 81b13ec..c379ab3 100644 --- a/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt @@ -4,8 +4,12 @@ package org.libremail.ui.accountsetup import androidx.lifecycle.SavedStateHandle import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.slot +import io.mockk.unmockkAll +import jakarta.mail.AuthenticationFailedException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -30,10 +34,22 @@ class AppPasswordViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() @Before - fun setUp() = Dispatchers.setMain(testDispatcher) + fun setUp() { + Dispatchers.setMain(testDispatcher) + // The IMAP-disabled branch breadcrumbs via AppLog -> android.util.Log, a throwing stub under + // plain JVM tests. Mock it fully-qualified so this file never imports android.util.Log (which + // detekt's ForbiddenImport guard would flag). + mockkStatic(android.util.Log::class) + every { android.util.Log.i(any(), any()) } returns 0 + every { android.util.Log.d(any(), any()) } returns 0 + every { android.util.Log.w(any(), any()) } returns 0 + } @After - fun tearDown() = Dispatchers.resetMain() + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } private fun viewModel(repo: AccountRepository, providerKey: String = MailProvider.GMAIL.key) = AppPasswordViewModel( SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to providerKey)), @@ -101,6 +117,29 @@ class AppPasswordViewModelTest { assertNull(vm.form.value.addedAccountId) } + @Test + fun `an IMAP-disabled failure surfaces the enable-IMAP prompt instead of a generic error`() = + runTest(testDispatcher) { + val repo = mockk() + coEvery { repo.addImapAccount(any(), any()) } returns + Result.failure(AuthenticationFailedException("Your account is not enabled for IMAP use")) + val vm = viewModel(repo) // Gmail preset + + vm.onEmail("user@gmail.com") + vm.onAppPassword("app-pass") + vm.testAndSave() + + val prompt = vm.form.value.imapDisabledPrompt + assertEquals(MailProvider.GMAIL.displayName, prompt?.brand) + assertTrue(prompt?.helpUrl?.startsWith("https://support.google.com/") == true) + assertNull(vm.form.value.error) + assertEquals(SetupStatus.IDLE, vm.form.value.status) + assertNull(vm.form.value.addedAccountId) + + vm.dismissImapDisabledPrompt() + assertNull(vm.form.value.imapDisabledPrompt) + } + @Test fun `an unknown provider key surfaces an error and never contacts the server`() = runTest(testDispatcher) { val repo = mockk(relaxed = true) diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/ImapDisabledDialogJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/ImapDisabledDialogJvmTest.kt new file mode 100644 index 0000000..886762b --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/ImapDisabledDialogJvmTest.kt @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import android.content.Context +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric JVM Compose test for the reactive "IMAP is disabled" dialog (#390). Drives the real + * [ImapDisabledDialog] on the JVM via the v2 `createComposeRule()` under [RobolectricTestRunner] — no + * emulator — so the dialog counts toward JaCoCo's JVM-testable surface. A recording [UriHandler] + * captures the help-link launch instead of opening a real browser; the instrumented + * [org.libremail.ui.accountsetup.AppPasswordSetupScreenTest] covers the end-to-end error state on + * device. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "+w411dp-h2000dp") +class ImapDisabledDialogJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int, vararg args: Any): String = context.getString(resId, *args) + + private val openedUrls = mutableListOf() + private val recordingUriHandler = object : UriHandler { + override fun openUri(uri: String) { + openedUrls.add(uri) + } + } + + private val outlookPrompt = ImapDisabledPrompt(brand = "Outlook", helpUrl = "https://support.microsoft.com/imap") + + private fun setContent(prompt: ImapDisabledPrompt, onDismiss: () -> Unit = {}) { + composeTestRule.setContent { + CompositionLocalProvider(LocalUriHandler provides recordingUriHandler) { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + ImapDisabledDialog(prompt = prompt, onDismiss = onDismiss) + } + } + } + } + + @Test + fun brandedPrompt_showsTitle_brandMessage_help_andDismiss() { + setContent(outlookPrompt) + + composeTestRule.onNodeWithText(string(R.string.imap_disabled_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.imap_disabled_message, "Outlook")).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.imap_disabled_help)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.imap_disabled_dismiss)).assertIsDisplayed() + } + + @Test + fun genericPrompt_showsGenericMessage_andNoHelpLink() { + setContent(ImapDisabledPrompt(brand = null, helpUrl = null)) + + composeTestRule.onNodeWithText(string(R.string.imap_disabled_message_generic)).assertIsDisplayed() + // No provider page to link, so the help button is absent — only "Got it" remains. + composeTestRule.onNodeWithText(string(R.string.imap_disabled_help)).assertDoesNotExist() + composeTestRule.onNodeWithText(string(R.string.imap_disabled_dismiss)).assertIsDisplayed() + } + + @Test + fun tappingHelp_opensTheProviderPage_withoutDismissing() { + var dismissed = false + setContent(outlookPrompt, onDismiss = { dismissed = true }) + + composeTestRule.onNodeWithText(string(R.string.imap_disabled_help)).performClick() + + assertEquals(listOf("https://support.microsoft.com/imap"), openedUrls) + // Opening the link leaves the dialog up so it is still there when the user returns. + assertFalse("Opening the help link must not dismiss the dialog", dismissed) + } + + @Test + fun tappingGotIt_dismisses() { + var dismissed = false + setContent(outlookPrompt, onDismiss = { dismissed = true }) + + composeTestRule.onNodeWithText(string(R.string.imap_disabled_dismiss)).performClick() + + assertTrue(dismissed) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/ImapDisabledPromptTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/ImapDisabledPromptTest.kt new file mode 100644 index 0000000..6bb6e93 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/ImapDisabledPromptTest.kt @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import jakarta.mail.AuthenticationFailedException +import org.junit.Test +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.MailProvider +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ServerConfig +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Unit tests for [imapDisabledPromptFor]: an "IMAP disabled" failure is turned into a provider-aware + * prompt (brand copy + the provider's enable-IMAP help link when one is known), while any other failure + * yields null so the caller keeps its generic error. Classification itself is covered by + * [org.libremail.mail.ImapAuthErrorTest]; this pins the brand/URL resolution. + */ +class ImapDisabledPromptTest { + + private fun manualAccount(host: String) = Account( + id = "imap:user@$host", + email = "user@$host", + displayName = "user@$host", + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig(host, 993, MailSecurity.SSL_TLS), + smtp = ServerConfig(host, 465, MailSecurity.SSL_TLS), + ) + + @Test + fun `outlook OAuth failure resolves to the Outlook brand and Microsoft help link`() { + val prompt = imapDisabledPromptFor( + AuthenticationFailedException("AUTHENTICATE failed"), + Account.outlook("me@outlook.com"), + usedOAuth = true, + ) + + assertEquals(MailProvider.OUTLOOK_BRAND, prompt?.brand) + assertTrue(prompt?.helpUrl?.startsWith("https://support.microsoft.com/") == true, prompt?.helpUrl) + } + + @Test + fun `gmail app-password failure resolves to the Gmail brand and Google help link`() { + val prompt = imapDisabledPromptFor( + AuthenticationFailedException("Your account is not enabled for IMAP use"), + MailProvider.GMAIL.createAccount("me@gmail.com"), + usedOAuth = false, + ) + + assertEquals(MailProvider.GMAIL.displayName, prompt?.brand) + assertTrue(prompt?.helpUrl?.startsWith("https://support.google.com/") == true, prompt?.helpUrl) + } + + @Test + fun `a manually-configured Gmail host is still recognised as Gmail`() { + // brandFor resolves the brand from the IMAP host, so a manual account on imap.gmail.com gets + // the Gmail copy + link even though it was set up through the generic path. + val prompt = imapDisabledPromptFor( + AuthenticationFailedException("IMAP is disabled"), + manualAccount("imap.gmail.com"), + usedOAuth = false, + ) + + assertEquals(MailProvider.GMAIL.displayName, prompt?.brand) + assertTrue(prompt?.helpUrl?.startsWith("https://support.google.com/") == true, prompt?.helpUrl) + } + + @Test + fun `a Yahoo failure keeps the brand but offers no link`() { + // Yahoo/iCloud/AOL gate access via app passwords, not a user-facing IMAP toggle we can deep-link. + val prompt = imapDisabledPromptFor( + AuthenticationFailedException("IMAP access is disabled"), + MailProvider.YAHOO.createAccount("me@yahoo.com"), + usedOAuth = false, + ) + + assertEquals(MailProvider.YAHOO.displayName, prompt?.brand) + assertNull(prompt?.helpUrl) + } + + @Test + fun `an unknown host yields a generic prompt with no brand and no link`() { + val prompt = imapDisabledPromptFor( + AuthenticationFailedException("IMAP access is disabled"), + manualAccount("mail.example.org"), + usedOAuth = false, + ) + + assertNull(prompt?.brand) + assertNull(prompt?.helpUrl) + } + + @Test + fun `a wrong-password failure yields no prompt`() { + val prompt = imapDisabledPromptFor( + AuthenticationFailedException("Invalid credentials"), + MailProvider.GMAIL.createAccount("me@gmail.com"), + usedOAuth = false, + ) + + assertNull(prompt) + } + + @Test + fun `ImapDisabledPrompt value semantics`() { + val prompt = ImapDisabledPrompt(brand = "Outlook", helpUrl = "https://example.test/imap") + assertEquals(prompt, prompt.copy()) + assertEquals(prompt.hashCode(), prompt.copy().hashCode()) + assertTrue(prompt.toString().contains("Outlook")) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenJvmTest.kt index 6e84ade..c0e8a1f 100644 --- a/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenJvmTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenJvmTest.kt @@ -212,4 +212,18 @@ class ManualSetupScreenJvmTest { composeTestRule.onAllNodesWithText("STARTTLS")[1].performClick() verify { vm.onSmtpSecurity(MailSecurity.STARTTLS) } } + + @Test + fun imapDisabledPrompt_showsTheGenericDialog_andGotItDismisses() { + // A manual account on an unknown host still gets the actionable "IMAP is off" dialog (#390), + // with the generic (brandless) message and no help link; "Got it" clears it via the view-model. + val vm = viewModel(ManualSetupForm(imapDisabledPrompt = ImapDisabledPrompt(brand = null, helpUrl = null))) + setContent(vm) + + composeTestRule.onNodeWithText(string(R.string.imap_disabled_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.imap_disabled_message_generic)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.imap_disabled_dismiss)).performClick() + + verify { vm.dismissImapDisabledPrompt() } + } } diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModelTest.kt index 882e1de..4134cf1 100644 --- a/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModelTest.kt @@ -3,8 +3,12 @@ package org.libremail.ui.accountsetup import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.slot +import io.mockk.unmockkAll +import jakarta.mail.AuthenticationFailedException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -29,10 +33,22 @@ class ManualSetupViewModelTest { private val dispatcher = UnconfinedTestDispatcher() @Before - fun setUp() = Dispatchers.setMain(dispatcher) + fun setUp() { + Dispatchers.setMain(dispatcher) + // The IMAP-disabled branch breadcrumbs via AppLog -> android.util.Log, a throwing stub under + // plain JVM tests. Mock it fully-qualified so this file never imports android.util.Log (which + // detekt's ForbiddenImport guard would flag). + mockkStatic(android.util.Log::class) + every { android.util.Log.i(any(), any()) } returns 0 + every { android.util.Log.d(any(), any()) } returns 0 + every { android.util.Log.w(any(), any()) } returns 0 + } @After - fun tearDown() = Dispatchers.resetMain() + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } private fun filled(vm: ManualSetupViewModel) { vm.onEmail(" user@example.org ") @@ -184,6 +200,28 @@ class ManualSetupViewModelTest { assertNull(vm.form.value.addedAccountId) } + @Test + fun `testAndSave surfaces the enable-IMAP prompt for an IMAP-disabled rejection`() = runTest(dispatcher) { + val repo = mockk() + coEvery { repo.addImapAccount(any(), any()) } returns + Result.failure(AuthenticationFailedException("IMAP access is disabled for this account")) + val vm = ManualSetupViewModel(repo) + filled(vm) // imap.example.org: a generic host with no known brand + + vm.testAndSave() + + val prompt = vm.form.value.imapDisabledPrompt + assertNotEquals(null, prompt) + assertNull(prompt?.brand) // unknown host -> generic message, no help link + assertNull(prompt?.helpUrl) + assertNull(vm.form.value.error) + assertEquals(SetupStatus.IDLE, vm.form.value.status) + assertNull(vm.form.value.addedAccountId) + + vm.dismissImapDisabledPrompt() + assertNull(vm.form.value.imapDisabledPrompt) + } + @Test fun `testAndSave uses a generic message when the failure has none`() = runTest(dispatcher) { val repo = mockk()