Change AutoDiscoveryResult
to a sealed interface
This commit is contained in:
parent
66bb36bd9f
commit
6a80fefd84
13 changed files with 134 additions and 63 deletions
|
@ -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)
|
||||
|
|
|
@ -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:")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
|
|
Loading…
Reference in a new issue