Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,13 @@
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.android.lint)
}
dependencies {
api(projects.feature.autodiscovery.autoconfig)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kxml2)
}

View file

@ -0,0 +1,82 @@
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
import kotlinx.coroutines.CoroutineStart.LAZY
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
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 [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 {
return coroutineScope {
val deferredList = buildList(capacity = runnables.size) {
// Create coroutines in reverse order. So ones with lower priority are created first.
for (runnable in runnables.reversed()) {
val lowerPriorityCoroutines = toList()
val deferred = async(coroutineDispatcher, start = LAZY) {
runnable.run().also { discoveryResult ->
if (discoveryResult is Settings) {
// We've got a positive result, so cancel all coroutines with lower priority.
lowerPriorityCoroutines.cancelAll()
}
}
}
add(deferred)
}
}.asReversed()
for (deferred in deferredList) {
deferred.start()
}
@Suppress("SwallowedException", "TooGenericExceptionCaught")
val discoveryResults = deferredList.map { deferred ->
try {
deferred.await()
} catch (e: CancellationException) {
null
} catch (e: Exception) {
UnexpectedException(e)
}
}
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
}
}
}
}
private fun List<Deferred<AutoDiscoveryResult?>>.cancelAll() {
for (deferred in this) {
deferred.cancel()
}
}
}

View file

@ -0,0 +1,42 @@
package app.k9mail.autodiscovery.service
import app.k9mail.autodiscovery.api.AutoDiscovery
import app.k9mail.autodiscovery.api.AutoDiscoveryRegistry
import app.k9mail.autodiscovery.autoconfig.AutoconfigUrlConfig
import app.k9mail.autodiscovery.autoconfig.createIspDbAutoconfigDiscovery
import app.k9mail.autodiscovery.autoconfig.createMxLookupAutoconfigDiscovery
import app.k9mail.autodiscovery.autoconfig.createProviderAutoconfigDiscovery
import okhttp3.OkHttpClient
class RealAutoDiscoveryRegistry(
private val autoDiscoveries: List<AutoDiscovery> = emptyList(),
) : AutoDiscoveryRegistry {
override fun getAutoDiscoveries(): List<AutoDiscovery> = autoDiscoveries
companion object {
private val defaultAutoconfigUrlConfig = AutoconfigUrlConfig(
httpsOnly = false,
includeEmailAddress = false,
)
fun createDefaultAutoDiscoveries(
okHttpClient: OkHttpClient,
autoconfigUrlConfig: AutoconfigUrlConfig = defaultAutoconfigUrlConfig,
): List<AutoDiscovery> {
return listOf(
createProviderAutoconfigDiscovery(
okHttpClient = okHttpClient,
config = autoconfigUrlConfig,
),
createIspDbAutoconfigDiscovery(
okHttpClient = okHttpClient,
),
createMxLookupAutoconfigDiscovery(
okHttpClient = okHttpClient,
config = autoconfigUrlConfig,
),
)
}
}
}

View file

@ -0,0 +1,21 @@
package app.k9mail.autodiscovery.service
import app.k9mail.autodiscovery.api.AutoDiscoveryRegistry
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryService
import net.thunderbird.core.common.mail.EmailAddress
/**
* Uses Thunderbird's Autoconfig mechanism to find mail server settings for a given email address.
*/
class RealAutoDiscoveryService(
private val autoDiscoveryRegistry: AutoDiscoveryRegistry,
) : AutoDiscoveryService {
override suspend fun discover(email: EmailAddress): AutoDiscoveryResult {
val runner = PriorityParallelRunner(
runnables = autoDiscoveryRegistry.getAutoDiscoveries().flatMap { it.initDiscovery(email) },
)
return runner.run()
}
}

View file

@ -0,0 +1,181 @@
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
import app.k9mail.autodiscovery.api.ImapServerSettings
import app.k9mail.autodiscovery.api.SmtpServerSettings
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import assertk.assertions.isTrue
import kotlin.test.Test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.common.net.toHostname
import net.thunderbird.core.common.net.toPort
@OptIn(ExperimentalCoroutinesApi::class)
class PriorityParallelRunnerTest {
@Test
fun `first runnable returning a success result should cancel remaining runnables`() = runTest {
var runnableTwoStarted = false
var runnableThreeStarted = false
var runnableTwoCompleted = false
var runnableThreeCompleted = false
val runnableOne = AutoDiscoveryRunnable {
delay(100)
DISCOVERY_RESULT_ONE
}
val runnableTwo = AutoDiscoveryRunnable {
runnableTwoStarted = true
delay(200)
runnableTwoCompleted = true
DISCOVERY_RESULT_TWO
}
val runnableThree = AutoDiscoveryRunnable {
runnableThreeStarted = true
delay(200)
runnableThreeCompleted = false
DISCOVERY_RESULT_TWO
}
val runner = PriorityParallelRunner(
runnables = listOf(runnableOne, runnableTwo, runnableThree),
coroutineDispatcher = StandardTestDispatcher(testScheduler),
)
var result: AutoDiscoveryResult? = null
launch {
result = runner.run()
}
testScheduler.advanceTimeBy(50)
assertThat(result).isNull()
assertThat(runnableTwoStarted).isTrue()
assertThat(runnableTwoCompleted).isFalse()
assertThat(runnableThreeStarted).isTrue()
assertThat(runnableThreeCompleted).isFalse()
testScheduler.advanceUntilIdle()
assertThat(result).isEqualTo(DISCOVERY_RESULT_ONE)
assertThat(runnableTwoCompleted).isFalse()
assertThat(runnableThreeCompleted).isFalse()
}
@Test
fun `highest priority result should be used even if it takes longer to be produced`() = runTest {
var runnableTwoCompleted = false
val runnableOne = AutoDiscoveryRunnable {
delay(100)
DISCOVERY_RESULT_ONE
}
val runnableTwo = AutoDiscoveryRunnable {
runnableTwoCompleted = true
DISCOVERY_RESULT_TWO
}
val runner = PriorityParallelRunner(
runnables = listOf(runnableOne, runnableTwo),
coroutineDispatcher = StandardTestDispatcher(testScheduler),
)
var result: AutoDiscoveryResult? = null
launch {
result = runner.run()
}
testScheduler.advanceTimeBy(50)
assertThat(result).isNull()
assertThat(runnableTwoCompleted).isTrue()
testScheduler.advanceUntilIdle()
assertThat(result).isEqualTo(DISCOVERY_RESULT_ONE)
}
@Test
fun `wait for higher priority runnable to complete`() = runTest {
var runnableOneCompleted = false
var runnableTwoCompleted = false
val runnableOne = AutoDiscoveryRunnable {
delay(100)
runnableOneCompleted = true
NO_DISCOVERY_RESULT
}
val runnableTwo = AutoDiscoveryRunnable {
runnableTwoCompleted = true
DISCOVERY_RESULT_TWO
}
val runner = PriorityParallelRunner(
runnables = listOf(runnableOne, runnableTwo),
coroutineDispatcher = StandardTestDispatcher(testScheduler),
)
var result: AutoDiscoveryResult? = null
launch {
result = runner.run()
}
testScheduler.advanceTimeBy(50)
assertThat(result).isNull()
assertThat(runnableOneCompleted).isFalse()
assertThat(runnableTwoCompleted).isTrue()
testScheduler.advanceTimeBy(100)
assertThat(result).isEqualTo(DISCOVERY_RESULT_TWO)
assertThat(runnableOneCompleted).isTrue()
}
companion object {
private val NO_DISCOVERY_RESULT: AutoDiscoveryResult = NoUsableSettingsFound
private val DISCOVERY_RESULT_ONE = AutoDiscoveryResult.Settings(
ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 993.toPort(),
connectionSecurity = TLS,
authenticationTypes = listOf(PasswordCleartext),
username = "user@domain.example",
),
SmtpServerSettings(
hostname = "smtp.domain.example".toHostname(),
port = 587.toPort(),
connectionSecurity = StartTLS,
authenticationTypes = listOf(PasswordCleartext),
username = "user@domain.example",
),
isTrusted = true,
source = "result 1",
)
private val DISCOVERY_RESULT_TWO = AutoDiscoveryResult.Settings(
ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 143.toPort(),
connectionSecurity = StartTLS,
authenticationTypes = listOf(PasswordCleartext),
username = "user@domain.example",
),
SmtpServerSettings(
hostname = "smtp.domain.example".toHostname(),
port = 465.toPort(),
connectionSecurity = TLS,
authenticationTypes = listOf(PasswordCleartext),
username = "user@domain.example",
),
isTrusted = true,
source = "result 2",
)
}
}

View file

@ -0,0 +1,30 @@
package app.k9mail.autodiscovery.service
import app.k9mail.autodiscovery.api.AutoDiscovery
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test
import net.thunderbird.core.common.mail.EmailAddress
class RealAutoDiscoveryRegistryTest {
@Test
fun `getAutoDiscoveries should return given discoveries`() {
val autoDiscoveries = listOf(
TestAutoDiscovery(),
TestAutoDiscovery(),
)
val testSubject = RealAutoDiscoveryRegistry(autoDiscoveries)
val result = testSubject.getAutoDiscoveries()
assertThat(result).isEqualTo(autoDiscoveries)
}
private class TestAutoDiscovery : AutoDiscovery {
override fun initDiscovery(email: EmailAddress): List<AutoDiscoveryRunnable> {
return emptyList()
}
}
}