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
20 changed files with 931 additions and 30 deletions
@@ -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()
}
}
}
@@ -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<Throwable> {
val seen = mutableListOf<Throwable>()
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"""),
)
}
@@ -169,6 +169,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)
}
}
}
}
@@ -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"
}
@@ -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)
}
}
}
@@ -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"
}
}
@@ -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)) }
},
)
}
@@ -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"
@@ -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)
}
}
}
@@ -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") }
}
}
}
+8
View File
@@ -205,6 +205,14 @@
<string name="account_setup_other">Other (IMAP/SMTP)</string>
<string name="account_setup_subtitle">Choose your email provider to get started.</string>
<!-- Reactive "IMAP is turned off" prompt shown when sign-in succeeds but the IMAP AUTHENTICATE
step is rejected because IMAP access is disabled on the account (#390) -->
<string name="imap_disabled_title">Turn on IMAP to continue</string>
<string name="imap_disabled_message">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.</string>
<string name="imap_disabled_message_generic">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.</string>
<string name="imap_disabled_help">How to turn on IMAP</string>
<string name="imap_disabled_dismiss">Got it</string>
<!-- Onboarding: pre-auth Outlook IMAP-enablement notice, shown before Microsoft sign-in (#411) -->
<string name="outlook_imap_title">Turn on IMAP for Outlook</string>
<string name="outlook_imap_question">Have you enabled IMAP for your Outlook account?</string>
@@ -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<String>(), any<String>()) } 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))
}
}
@@ -229,4 +229,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() }
}
}
@@ -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<OutlookAuthManager>(relaxed = true)
coEvery { manager.exchangeToken(any()) } returns
OAuthResult(email = "me@outlook.com", accessToken = "tok", authStateJson = "{}")
val accounts = mockk<AccountRepository>(relaxed = true)
coEvery { accounts.addOutlookAccount(any(), any(), any()) } returns
Result.failure(AuthenticationFailedException("AUTHENTICATE failed"))
val vm = viewModel(outlookAuthManager = manager, accountRepository = accounts)
vm.onOutlookResult(mockk<Intent>(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<OutlookAuthManager>(relaxed = true)
coEvery { manager.exchangeToken(any()) } returns
OAuthResult(email = "me@outlook.com", accessToken = "tok", authStateJson = "{}")
val accounts = mockk<AccountRepository>(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<Intent>(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) {
@@ -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() }
}
}
@@ -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<String>(), any<String>()) } 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<AccountRepository>()
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<AccountRepository>(relaxed = true)
@@ -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<String>()
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)
}
}
@@ -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"))
}
}
@@ -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() }
}
}
@@ -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<String>(), any<String>()) } 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<AccountRepository>()
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<AccountRepository>()