Change AutoDiscoveryResult to a sealed interface

This commit is contained in:
cketti 2023-06-01 20:11:42 +02:00
parent 66bb36bd9f
commit 6a80fefd84
13 changed files with 134 additions and 63 deletions

View file

@ -1,6 +1,7 @@
package app.k9mail.cli.autodiscovery
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings
import app.k9mail.autodiscovery.autoconfig.AutoconfigUrlConfig
import app.k9mail.autodiscovery.autoconfig.createIspDbAutoconfigDiscovery
import app.k9mail.autodiscovery.autoconfig.createMxLookupAutoconfigDiscovery
@ -37,18 +38,18 @@ class AutoDiscoveryCli : CliktCommand(
runAutoDiscovery(config)
}
if (discoveryResult == null) {
echo("Couldn't find any mail server settings.")
} else {
if (discoveryResult is Settings) {
echo("Found the following mail server settings:")
AutoDiscoveryResultFormatter(::echo).output(discoveryResult)
} else {
echo("Couldn't find any mail server settings.")
}
echo()
echo("Duration: ${durationInMillis.toDuration(MILLISECONDS)}")
}
private fun runAutoDiscovery(config: AutoconfigUrlConfig): AutoDiscoveryResult? {
private fun runAutoDiscovery(config: AutoconfigUrlConfig): AutoDiscoveryResult {
val okHttpClient = Builder().build()
try {
val providerDiscovery = createProviderAutoconfigDiscovery(okHttpClient, config)

View file

@ -1,13 +1,13 @@
package app.k9mail.cli.autodiscovery
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings
import app.k9mail.autodiscovery.api.ImapServerSettings
import app.k9mail.autodiscovery.api.SmtpServerSettings
internal class AutoDiscoveryResultFormatter(private val echo: (String) -> Unit) {
fun output(discoveryResult: AutoDiscoveryResult) {
val incomingServer = requireNotNull(discoveryResult.incomingServerSettings as? ImapServerSettings)
val outgoingServer = requireNotNull(discoveryResult.outgoingServerSettings as? SmtpServerSettings)
fun output(settings: Settings) {
val incomingServer = requireNotNull(settings.incomingServerSettings as? ImapServerSettings)
val outgoingServer = requireNotNull(settings.outgoingServerSettings as? SmtpServerSettings)
echo("------------------------------")
echo("Incoming server:")

View file

@ -1,20 +1,45 @@
package app.k9mail.cli.autodiscovery
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NetworkError
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NoUsableSettingsFound
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.UnexpectedException
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
import com.fsck.k9.logging.Timber
/**
* Run a list of [AutoDiscoveryRunnable] one after the other until one returns a non-`null` result.
* Run a list of [AutoDiscoveryRunnable] one after the other until one returns a [Settings] result.
*/
class SerialRunner(private val runnables: List<AutoDiscoveryRunnable>) {
suspend fun run(): AutoDiscoveryResult? {
suspend fun run(): AutoDiscoveryResult {
var networkErrorCount = 0
var networkError: NetworkError? = null
for (runnable in runnables) {
val discoveryResult = runnable.run()
if (discoveryResult != null) {
return discoveryResult
when (val discoveryResult = runnable.run()) {
is Settings -> {
return discoveryResult
}
is NetworkError -> {
networkErrorCount++
if (networkError == null) {
networkError = discoveryResult
}
}
NoUsableSettingsFound -> {
Unit
}
is UnexpectedException -> {
Timber.w(discoveryResult.exception, "Unexpected exception")
}
}
}
return null
return if (networkError != null && networkErrorCount == runnables.size) {
networkError
} else {
NoUsableSettingsFound
}
}
}

View file

@ -1,12 +1,34 @@
package app.k9mail.autodiscovery.api
import java.io.IOException
/**
* Results of a mail server settings lookup.
*/
data class AutoDiscoveryResult(
val incomingServerSettings: IncomingServerSettings,
val outgoingServerSettings: OutgoingServerSettings,
)
sealed interface AutoDiscoveryResult {
/**
* Mail server settings found during the lookup.
*/
data class Settings(
val incomingServerSettings: IncomingServerSettings,
val outgoingServerSettings: OutgoingServerSettings,
) : AutoDiscoveryResult
/**
* No usable mail server settings were found.
*/
object NoUsableSettingsFound : AutoDiscoveryResult
/**
* A network error occurred while looking for mail server settings.
*/
data class NetworkError(val exception: IOException) : AutoDiscoveryResult
/**
* Encountered an unexpected exception when looking up mail server settings.
*/
data class UnexpectedException(val exception: Exception) : AutoDiscoveryResult
}
/**
* Incoming mail server settings.

View file

@ -6,5 +6,5 @@ package app.k9mail.autodiscovery.api
* This is an abstraction that allows us to run multiple lookups in parallel.
*/
fun interface AutoDiscoveryRunnable {
suspend fun run(): AutoDiscoveryResult?
suspend fun run(): AutoDiscoveryResult
}

View file

@ -8,5 +8,5 @@ import okhttp3.HttpUrl
* Fetches and parses Autoconfig settings.
*/
internal interface AutoconfigFetcher {
suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult?
suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult
}

View file

@ -2,6 +2,8 @@ package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.AutoDiscovery
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NoUsableSettingsFound
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
import app.k9mail.core.common.mail.EmailAddress
import app.k9mail.core.common.mail.toDomain
@ -27,31 +29,34 @@ class MxLookupAutoconfigDiscovery internal constructor(
}
@Suppress("ReturnCount")
private suspend fun mxLookupAutoconfig(email: EmailAddress): AutoDiscoveryResult? {
private suspend fun mxLookupAutoconfig(email: EmailAddress): AutoDiscoveryResult {
val domain = email.domain.toDomain()
val mxHostName = mxLookup(domain) ?: return null
val mxHostName = mxLookup(domain) ?: return NoUsableSettingsFound
val mxBaseDomain = getMxBaseDomain(mxHostName)
if (mxBaseDomain == domain) {
// Exit early to match Thunderbird's behavior.
return null
return NoUsableSettingsFound
}
// In addition to just the base domain, also check the MX hostname without the first label to differentiate
// between Outlook.com/Hotmail and Office365 business domains.
val mxSubDomain = getNextSubDomain(mxHostName)?.takeIf { it != mxBaseDomain }
var latestResult: AutoDiscoveryResult = NoUsableSettingsFound
for (domainToCheck in listOfNotNull(mxSubDomain, mxBaseDomain)) {
for (autoconfigUrl in urlProvider.getAutoconfigUrls(domainToCheck)) {
val discoveryResult = autoconfigFetcher.fetchAutoconfig(autoconfigUrl, email)
if (discoveryResult != null) {
if (discoveryResult is Settings) {
return discoveryResult
}
latestResult = discoveryResult
}
}
return null
return latestResult
}
private suspend fun mxLookup(domain: Domain): Domain? {

View file

@ -15,7 +15,7 @@ internal class RealAutoconfigFetcher(
private val fetcher: HttpFetcher,
private val parser: SuspendableAutoconfigParser,
) : AutoconfigFetcher {
override suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult? {
override suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult {
return try {
when (val fetchResult = fetcher.fetch(autoconfigUrl)) {
is SuccessResponse -> {
@ -23,11 +23,11 @@ internal class RealAutoconfigFetcher(
parseSettings(inputStream, email, autoconfigUrl)
}
}
is ErrorResponse -> null
is ErrorResponse -> AutoDiscoveryResult.NoUsableSettingsFound
}
} catch (e: IOException) {
Timber.d(e, "Error fetching Autoconfig from URL: %s", autoconfigUrl)
null
AutoDiscoveryResult.NetworkError(e)
}
}
@ -35,20 +35,20 @@ internal class RealAutoconfigFetcher(
inputStream: InputStream,
email: EmailAddress,
autoconfigUrl: HttpUrl,
): AutoDiscoveryResult? {
): AutoDiscoveryResult {
return try {
return when (val parserResult = parser.parseSettings(inputStream, email)) {
is Settings -> {
AutoDiscoveryResult(
AutoDiscoveryResult.Settings(
incomingServerSettings = parserResult.incomingServerSettings,
outgoingServerSettings = parserResult.outgoingServerSettings,
)
}
is ParserError -> null
is ParserError -> AutoDiscoveryResult.NoUsableSettingsFound
}
} catch (e: AutoconfigParserException) {
Timber.d(e, "Failed to parse config from URL: %s", autoconfigUrl)
null
AutoDiscoveryResult.NoUsableSettingsFound
}
}
}

View file

@ -18,13 +18,13 @@ internal class MockAutoconfigFetcher : AutoconfigFetcher {
val callCount: Int
get() = callArguments.size
private val results = mutableListOf<AutoDiscoveryResult?>()
private val results = mutableListOf<AutoDiscoveryResult>()
fun addResult(discoveryResult: AutoDiscoveryResult?) {
fun addResult(discoveryResult: AutoDiscoveryResult) {
results.add(discoveryResult)
}
override suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult? {
override suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult {
callArguments.add(autoconfigUrl to email)
check(results.isNotEmpty()) {
@ -34,7 +34,7 @@ internal class MockAutoconfigFetcher : AutoconfigFetcher {
}
companion object {
val RESULT_ONE = AutoDiscoveryResult(
val RESULT_ONE = AutoDiscoveryResult.Settings(
incomingServerSettings = ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 993.toPort(),
@ -50,7 +50,7 @@ internal class MockAutoconfigFetcher : AutoconfigFetcher {
username = "irrelevant@domain.example",
),
)
val RESULT_TWO = AutoDiscoveryResult(
val RESULT_TWO = AutoDiscoveryResult.Settings(
incomingServerSettings = ImapServerSettings(
hostname = "imap.company.example".toHostname(),
port = 143.toPort(),

View file

@ -1,5 +1,6 @@
package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NoUsableSettingsFound
import app.k9mail.autodiscovery.autoconfig.MockAutoconfigFetcher.Companion.RESULT_ONE
import app.k9mail.core.common.mail.toEmailAddress
import app.k9mail.core.common.net.toDomain
@ -8,7 +9,6 @@ import assertk.assertions.containsExactly
import assertk.assertions.extracting
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import kotlin.test.Test
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
@ -57,8 +57,8 @@ class MxLookupAutoconfigDiscoveryTest {
addResult(listOf("https://ispdb.invalid/emailprovider.example".toHttpUrl()))
}
autoconfigFetcher.apply {
addResult(null)
addResult(null)
addResult(NoUsableSettingsFound)
addResult(NoUsableSettingsFound)
}
val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress)
@ -69,7 +69,7 @@ class MxLookupAutoconfigDiscoveryTest {
"emailprovider.example".toDomain(),
)
assertThat(autoconfigFetcher.callCount).isEqualTo(2)
assertThat(discoveryResult).isNull()
assertThat(discoveryResult).isEqualTo(NoUsableSettingsFound)
}
@Test
@ -83,7 +83,7 @@ class MxLookupAutoconfigDiscoveryTest {
assertThat(mxResolver.callCount).isEqualTo(1)
assertThat(urlProvider.callCount).isEqualTo(0)
assertThat(autoconfigFetcher.callCount).isEqualTo(0)
assertThat(discoveryResult).isNull()
assertThat(discoveryResult).isEqualTo(NoUsableSettingsFound)
}
@Test
@ -97,6 +97,6 @@ class MxLookupAutoconfigDiscoveryTest {
assertThat(mxResolver.callCount).isEqualTo(1)
assertThat(urlProvider.callCount).isEqualTo(0)
assertThat(autoconfigFetcher.callCount).isEqualTo(0)
assertThat(discoveryResult).isNull()
assertThat(discoveryResult).isEqualTo(NoUsableSettingsFound)
}
}

View file

@ -1,6 +1,10 @@
package app.k9mail.autodiscovery.service
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NetworkError
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NoUsableSettingsFound
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.UnexpectedException
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
@ -14,13 +18,13 @@ import kotlinx.coroutines.coroutineScope
* Runs a list of [AutoDiscoveryRunnable]s with descending priority in parallel and returns the result with the highest
* priority.
*
* As soon as an [AutoDiscoveryRunnable] returns a non-`null` result, runnables with a lower priority are canceled.
* As soon as an [AutoDiscoveryRunnable] returns a [Settings] result, runnables with a lower priority are canceled.
*/
internal class PriorityParallelRunner(
private val runnables: List<AutoDiscoveryRunnable>,
private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun run(): AutoDiscoveryResult? {
suspend fun run(): AutoDiscoveryResult {
return coroutineScope {
val deferredList = buildList(capacity = runnables.size) {
// Create coroutines in reverse order. So ones with lower priority are created first.
@ -28,9 +32,11 @@ internal class PriorityParallelRunner(
val lowerPriorityCoroutines = toList()
val deferred = async(coroutineDispatcher, start = LAZY) {
runnable.run()?.also {
// We've got a non-null result, so cancel all coroutines with lower priority.
lowerPriorityCoroutines.cancelAll()
runnable.run().also { discoveryResult ->
if (discoveryResult is Settings) {
// We've got a positive result, so cancel all coroutines with lower priority.
lowerPriorityCoroutines.cancelAll()
}
}
}
@ -42,18 +48,29 @@ internal class PriorityParallelRunner(
deferred.start()
}
@Suppress("SwallowedException")
deferredList
.map { deferred ->
try {
deferred.await()
} catch (e: CancellationException) {
null
}
@Suppress("SwallowedException", "TooGenericExceptionCaught")
val discoveryResults = deferredList.map { deferred ->
try {
deferred.await()
} catch (e: CancellationException) {
null
} catch (e: Exception) {
UnexpectedException(e)
}
.asSequence()
.filterNotNull()
.firstOrNull()
}
val settingsResult = discoveryResults.firstOrNull { it is Settings }
if (settingsResult != null) {
settingsResult
} else {
val networkError = discoveryResults.firstOrNull { it is NetworkError }
val networkErrorCount = discoveryResults.count { it is NetworkError }
if (networkError != null && networkErrorCount == discoveryResults.size) {
networkError
} else {
NoUsableSettingsFound
}
}
}
}

View file

@ -16,7 +16,7 @@ class RealAutoDiscoveryService(
private val okHttpClient: OkHttpClient,
) : AutoDiscoveryService {
override suspend fun discover(email: EmailAddress): AutoDiscoveryResult? {
override suspend fun discover(email: EmailAddress): AutoDiscoveryResult {
val config = AutoconfigUrlConfig(
httpsOnly = false,
includeEmailAddress = false,

View file

@ -2,6 +2,7 @@ package app.k9mail.autodiscovery.service
import app.k9mail.autodiscovery.api.AuthenticationType.PasswordCleartext
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NoUsableSettingsFound
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
import app.k9mail.autodiscovery.api.ConnectionSecurity.StartTLS
import app.k9mail.autodiscovery.api.ConnectionSecurity.TLS
@ -137,9 +138,9 @@ class PriorityParallelRunnerTest {
}
companion object {
private val NO_DISCOVERY_RESULT: AutoDiscoveryResult? = null
private val NO_DISCOVERY_RESULT: AutoDiscoveryResult = NoUsableSettingsFound
private val DISCOVERY_RESULT_ONE = AutoDiscoveryResult(
private val DISCOVERY_RESULT_ONE = AutoDiscoveryResult.Settings(
ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 993.toPort(),
@ -156,7 +157,7 @@ class PriorityParallelRunnerTest {
),
)
private val DISCOVERY_RESULT_TWO = AutoDiscoveryResult(
private val DISCOVERY_RESULT_TWO = AutoDiscoveryResult.Settings(
ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 143.toPort(),