feat(auth): detect "IMAP disabled" AUTHENTICATE failures and prompt the user to enable IMAP #430
+42
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
Reference in New Issue
Block a user