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,9 @@
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.android.lint)
}
dependencies {
api(projects.mail.common)
api(projects.core.common)
}

View file

@ -0,0 +1,13 @@
package app.k9mail.autodiscovery.api
/**
* The authentication types supported when using the [AutoDiscovery] mechanism.
*
* Note: Currently we support the same set of values in [ImapServerSettings] and [SmtpServerSettings]. As soon as this
* changes, this type should be replaced with `ImapAuthenticationType` and `SmtpAuthenticationType`.
*/
enum class AuthenticationType {
PasswordCleartext,
PasswordEncrypted,
OAuth2,
}

View file

@ -0,0 +1,13 @@
package app.k9mail.autodiscovery.api
import net.thunderbird.core.common.mail.EmailAddress
/**
* Provides a mechanism to find mail server settings for a given email address.
*/
interface AutoDiscovery {
/**
* Returns a list of [AutoDiscoveryRunnable]s that perform the actual mail server settings discovery.
*/
fun initDiscovery(email: EmailAddress): List<AutoDiscoveryRunnable>
}

View file

@ -0,0 +1,5 @@
package app.k9mail.autodiscovery.api
interface AutoDiscoveryRegistry {
fun getAutoDiscoveries(): List<AutoDiscovery>
}

View file

@ -0,0 +1,61 @@
package app.k9mail.autodiscovery.api
import java.io.IOException
/**
* Results of a mail server settings lookup.
*/
sealed interface AutoDiscoveryResult {
/**
* Mail server settings found during the lookup.
*/
data class Settings(
val incomingServerSettings: IncomingServerSettings,
val outgoingServerSettings: OutgoingServerSettings,
/**
* Indicates whether the mail server settings lookup was using only trusted channels.
*
* `true` if the settings lookup was only using trusted channels, e.g. lookup via HTTPS where the server
* presented a trusted certificate. `false´ otherwise.
*
* IMPORTANT: When this value is `false`, the settings should be presented to the user and only be used after
* the user has given consent.
*/
val isTrusted: Boolean,
/**
* String describing the source of the server settings. Use a URI if possible.
*/
val source: String,
) : 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.
*
* Implementations contain protocol-specific properties.
*/
interface IncomingServerSettings
/**
* Outgoing mail server settings.
*
* Implementations contain protocol-specific properties.
*/
interface OutgoingServerSettings

View file

@ -0,0 +1,10 @@
package app.k9mail.autodiscovery.api
/**
* Performs a mail server settings lookup.
*
* This is an abstraction that allows us to run multiple lookups in parallel.
*/
fun interface AutoDiscoveryRunnable {
suspend fun run(): AutoDiscoveryResult
}

View file

@ -0,0 +1,10 @@
package app.k9mail.autodiscovery.api
import net.thunderbird.core.common.mail.EmailAddress
/**
* Tries to find mail server settings for a given email address.
*/
interface AutoDiscoveryService {
suspend fun discover(email: EmailAddress): AutoDiscoveryResult
}

View file

@ -0,0 +1,9 @@
package app.k9mail.autodiscovery.api
/**
* The connection security methods supported when using the [AutoDiscovery] mechanism.
*/
enum class ConnectionSecurity {
StartTLS,
TLS,
}

View file

@ -0,0 +1,12 @@
package app.k9mail.autodiscovery.api
import net.thunderbird.core.common.net.Hostname
import net.thunderbird.core.common.net.Port
data class ImapServerSettings(
val hostname: Hostname,
val port: Port,
val connectionSecurity: ConnectionSecurity,
val authenticationTypes: List<AuthenticationType>,
val username: String,
) : IncomingServerSettings

View file

@ -0,0 +1,12 @@
package app.k9mail.autodiscovery.api
import net.thunderbird.core.common.net.Hostname
import net.thunderbird.core.common.net.Port
data class SmtpServerSettings(
val hostname: Hostname,
val port: Port,
val connectionSecurity: ConnectionSecurity,
val authenticationTypes: List<AuthenticationType>,
val username: String,
) : OutgoingServerSettings

View file

@ -0,0 +1,19 @@
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.android.lint)
}
dependencies {
api(projects.feature.autodiscovery.api)
api(libs.okhttp)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.minidns.hla)
compileOnly(libs.xmlpull)
testImplementation(projects.core.logging.testing)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kxml2)
testImplementation(libs.jsoup)
testImplementation(libs.okhttp.mockwebserver)
}

View file

@ -0,0 +1,49 @@
package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.AutoDiscovery
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
import net.thunderbird.core.common.mail.EmailAddress
import net.thunderbird.core.common.mail.toDomain
import okhttp3.OkHttpClient
class AutoconfigDiscovery internal constructor(
private val urlProvider: AutoconfigUrlProvider,
private val autoconfigFetcher: AutoconfigFetcher,
) : AutoDiscovery {
override fun initDiscovery(email: EmailAddress): List<AutoDiscoveryRunnable> {
val domain = email.domain.toDomain()
val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, email)
return autoconfigUrls.map { autoconfigUrl ->
AutoDiscoveryRunnable {
autoconfigFetcher.fetchAutoconfig(autoconfigUrl, email)
}
}
}
}
fun createProviderAutoconfigDiscovery(
okHttpClient: OkHttpClient,
config: AutoconfigUrlConfig,
): AutoconfigDiscovery {
val urlProvider = ProviderAutoconfigUrlProvider(config)
return createAutoconfigDiscovery(okHttpClient, urlProvider)
}
fun createIspDbAutoconfigDiscovery(okHttpClient: OkHttpClient): AutoconfigDiscovery {
val urlProvider = IspDbAutoconfigUrlProvider()
return createAutoconfigDiscovery(okHttpClient, urlProvider)
}
private fun createAutoconfigDiscovery(
okHttpClient: OkHttpClient,
urlProvider: AutoconfigUrlProvider,
): AutoconfigDiscovery {
val autoconfigFetcher = RealAutoconfigFetcher(
fetcher = OkHttpFetcher(okHttpClient),
parser = SuspendableAutoconfigParser(RealAutoconfigParser()),
)
return AutoconfigDiscovery(urlProvider, autoconfigFetcher)
}

View file

@ -0,0 +1,12 @@
package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import net.thunderbird.core.common.mail.EmailAddress
import okhttp3.HttpUrl
/**
* Fetches and parses Autoconfig settings.
*/
internal interface AutoconfigFetcher {
suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult
}

View file

@ -0,0 +1,13 @@
package app.k9mail.autodiscovery.autoconfig
import java.io.InputStream
import net.thunderbird.core.common.mail.EmailAddress
/**
* Parser for Thunderbird's Autoconfig file format.
*
* See [https://github.com/thunderbird/autoconfig](https://github.com/thunderbird/autoconfig)
*/
internal interface AutoconfigParser {
fun parseSettings(inputStream: InputStream, email: EmailAddress): AutoconfigParserResult
}

View file

@ -0,0 +1,3 @@
package app.k9mail.autodiscovery.autoconfig
class AutoconfigParserException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)

View file

@ -0,0 +1,27 @@
package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.IncomingServerSettings
import app.k9mail.autodiscovery.api.OutgoingServerSettings
/**
* Result type for [AutoconfigParser].
*/
internal sealed interface AutoconfigParserResult {
/**
* Server settings extracted from the Autoconfig XML.
*/
data class Settings(
val incomingServerSettings: List<IncomingServerSettings>,
val outgoingServerSettings: List<OutgoingServerSettings>,
) : AutoconfigParserResult {
init {
require(incomingServerSettings.isNotEmpty())
require(outgoingServerSettings.isNotEmpty())
}
}
/**
* Server settings couldn't be extracted.
*/
data class ParserError(val error: AutoconfigParserException) : AutoconfigParserResult
}

View file

@ -0,0 +1,9 @@
package app.k9mail.autodiscovery.autoconfig
import net.thunderbird.core.common.mail.EmailAddress
import net.thunderbird.core.common.net.Domain
import okhttp3.HttpUrl
internal interface AutoconfigUrlProvider {
fun getAutoconfigUrls(domain: Domain, email: EmailAddress? = null): List<HttpUrl>
}

View file

@ -0,0 +1,12 @@
package app.k9mail.autodiscovery.autoconfig
import net.thunderbird.core.common.net.Domain
/**
* Extract the base domain from a host name.
*
* An implementation needs to respect the [Public Suffix List](https://publicsuffix.org/).
*/
internal interface BaseDomainExtractor {
fun extractBaseDomain(domain: Domain): Domain
}

View file

@ -0,0 +1,26 @@
package app.k9mail.autodiscovery.autoconfig
import java.io.InputStream
/**
* Result type for [HttpFetcher].
*/
internal sealed interface HttpFetchResult {
/**
* The HTTP request returned a success response.
*
* @param inputStream Contains the response body.
* @param isTrusted `true` iff all associated requests were using HTTPS with a trusted certificate.
*/
data class SuccessResponse(
val inputStream: InputStream,
val isTrusted: Boolean,
) : HttpFetchResult
/**
* The server returned an error response.
*
* @param code The HTTP status code.
*/
data class ErrorResponse(val code: Int) : HttpFetchResult
}

View file

@ -0,0 +1,7 @@
package app.k9mail.autodiscovery.autoconfig
import okhttp3.HttpUrl
internal interface HttpFetcher {
suspend fun fetch(url: HttpUrl): HttpFetchResult
}

View file

@ -0,0 +1,20 @@
package app.k9mail.autodiscovery.autoconfig
import net.thunderbird.core.common.mail.EmailAddress
import net.thunderbird.core.common.net.Domain
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
internal class IspDbAutoconfigUrlProvider : AutoconfigUrlProvider {
override fun getAutoconfigUrls(domain: Domain, email: EmailAddress?): List<HttpUrl> {
return listOf(createIspDbUrl(domain))
}
private fun createIspDbUrl(domain: Domain): HttpUrl {
// https://autoconfig.thunderbird.net/v1.1/{domain}
return "https://autoconfig.thunderbird.net/v1.1/".toHttpUrl()
.newBuilder()
.addPathSegment(domain.value)
.build()
}
}

View file

@ -0,0 +1,21 @@
package app.k9mail.autodiscovery.autoconfig
import net.thunderbird.core.common.net.Domain
import net.thunderbird.core.common.net.toDomainOrNull
import org.minidns.hla.DnssecResolverApi
import org.minidns.record.MX
internal class MiniDnsMxResolver : MxResolver {
override fun lookup(domain: Domain): MxLookupResult {
val result = DnssecResolverApi.INSTANCE.resolve(domain.value, MX::class.java)
val mxNames = result.answersOrEmptySet
.sortedBy { it.priority }
.mapNotNull { it.target.toString().toDomainOrNull() }
return MxLookupResult(
mxNames = mxNames,
isTrusted = if (result.wasSuccessful()) result.isAuthenticData else false,
)
}
}

View file

@ -0,0 +1,104 @@
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 java.io.IOException
import net.thunderbird.core.common.mail.EmailAddress
import net.thunderbird.core.common.mail.toDomain
import net.thunderbird.core.common.net.Domain
import net.thunderbird.core.logging.legacy.Log
import okhttp3.OkHttpClient
import org.minidns.dnsname.InvalidDnsNameException
class MxLookupAutoconfigDiscovery internal constructor(
private val mxResolver: SuspendableMxResolver,
private val baseDomainExtractor: BaseDomainExtractor,
private val subDomainExtractor: SubDomainExtractor,
private val urlProvider: AutoconfigUrlProvider,
private val autoconfigFetcher: AutoconfigFetcher,
) : AutoDiscovery {
override fun initDiscovery(email: EmailAddress): List<AutoDiscoveryRunnable> {
return listOf(
AutoDiscoveryRunnable {
mxLookupAutoconfig(email)
},
)
}
@Suppress("ReturnCount")
private suspend fun mxLookupAutoconfig(email: EmailAddress): AutoDiscoveryResult {
val domain = email.domain.toDomain()
val mxLookupResult = mxLookup(domain) ?: return NoUsableSettingsFound
val mxHostName = mxLookupResult.mxNames.first()
val mxBaseDomain = getMxBaseDomain(mxHostName)
if (mxBaseDomain == domain) {
// Exit early to match Thunderbird's behavior.
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, email)) {
val discoveryResult = autoconfigFetcher.fetchAutoconfig(autoconfigUrl, email)
if (discoveryResult is Settings) {
return discoveryResult.copy(
isTrusted = mxLookupResult.isTrusted && discoveryResult.isTrusted,
)
}
latestResult = discoveryResult
}
}
return latestResult
}
private suspend fun mxLookup(domain: Domain): MxLookupResult? {
// Only return the most preferred entry to match Thunderbird's behavior.
return try {
mxResolver.lookup(domain).takeIf { it.mxNames.isNotEmpty() }
} catch (e: IOException) {
Log.d(e, "Failed to get MX record for domain: %s", domain.value)
null
} catch (e: InvalidDnsNameException) {
Log.d(e, "Invalid DNS name for domain: %s", domain.value)
null
}
}
private fun getMxBaseDomain(mxHostName: Domain): Domain {
return baseDomainExtractor.extractBaseDomain(mxHostName)
}
private fun getNextSubDomain(domain: Domain): Domain? {
return subDomainExtractor.extractSubDomain(domain)
}
}
fun createMxLookupAutoconfigDiscovery(
okHttpClient: OkHttpClient,
config: AutoconfigUrlConfig,
): MxLookupAutoconfigDiscovery {
val baseDomainExtractor = OkHttpBaseDomainExtractor()
val autoconfigFetcher = RealAutoconfigFetcher(
fetcher = OkHttpFetcher(okHttpClient),
parser = SuspendableAutoconfigParser(RealAutoconfigParser()),
)
return MxLookupAutoconfigDiscovery(
mxResolver = SuspendableMxResolver(MiniDnsMxResolver()),
baseDomainExtractor = baseDomainExtractor,
subDomainExtractor = RealSubDomainExtractor(baseDomainExtractor),
urlProvider = createPostMxLookupAutoconfigUrlProvider(config),
autoconfigFetcher = autoconfigFetcher,
)
}

View file

@ -0,0 +1,14 @@
package app.k9mail.autodiscovery.autoconfig
import net.thunderbird.core.common.net.Domain
/**
* Result for [MxResolver].
*
* @param mxNames The hostnames from the MX records.
* @param isTrusted `true` iff the results were properly signed (DNSSEC) or were retrieved using a secure channel.
*/
data class MxLookupResult(
val mxNames: List<Domain>,
val isTrusted: Boolean,
)

View file

@ -0,0 +1,10 @@
package app.k9mail.autodiscovery.autoconfig
import net.thunderbird.core.common.net.Domain
/**
* Look up MX records for a domain.
*/
internal interface MxResolver {
fun lookup(domain: Domain): MxLookupResult
}

View file

@ -0,0 +1,18 @@
package app.k9mail.autodiscovery.autoconfig
import net.thunderbird.core.common.net.Domain
import net.thunderbird.core.common.net.toDomain
import okhttp3.HttpUrl
internal class OkHttpBaseDomainExtractor : BaseDomainExtractor {
override fun extractBaseDomain(domain: Domain): Domain {
return domain.value.toHttpUrlOrNull().topPrivateDomain()?.toDomain() ?: domain
}
private fun String.toHttpUrlOrNull(): HttpUrl {
return HttpUrl.Builder()
.scheme("https")
.host(this)
.build()
}
}

View file

@ -0,0 +1,63 @@
package app.k9mail.autodiscovery.autoconfig
import java.io.IOException
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request.Builder
import okhttp3.Response
internal class OkHttpFetcher(
private val okHttpClient: OkHttpClient,
) : HttpFetcher {
override suspend fun fetch(url: HttpUrl): HttpFetchResult {
return suspendCancellableCoroutine { cancellableContinuation ->
val request = Builder()
.url(url)
.build()
val call = okHttpClient.newCall(request)
cancellableContinuation.invokeOnCancellation {
call.cancel()
}
val responseCallback = object : Callback {
override fun onFailure(call: Call, e: IOException) {
cancellableContinuation.cancel(e)
}
override fun onResponse(call: Call, response: Response) {
if (response.isSuccessful) {
val result = HttpFetchResult.SuccessResponse(
inputStream = response.body!!.byteStream(),
isTrusted = response.isTrusted(),
)
cancellableContinuation.resume(result)
} else {
// We don't care about the body of error responses.
response.close()
val result = HttpFetchResult.ErrorResponse(response.code)
cancellableContinuation.resume(result)
}
}
}
call.enqueue(responseCallback)
}
}
private tailrec fun Response.isTrusted(): Boolean {
val priorResponse = priorResponse
return when {
!request.isHttps -> false
priorResponse == null -> true
else -> priorResponse.isTrusted()
}
}
}

View file

@ -0,0 +1,39 @@
package app.k9mail.autodiscovery.autoconfig
import net.thunderbird.core.common.mail.EmailAddress
import net.thunderbird.core.common.net.Domain
import okhttp3.HttpUrl
internal class PostMxLookupAutoconfigUrlProvider(
private val ispDbUrlProvider: AutoconfigUrlProvider,
private val config: AutoconfigUrlConfig,
) : AutoconfigUrlProvider {
override fun getAutoconfigUrls(domain: Domain, email: EmailAddress?): List<HttpUrl> {
return buildList {
add(createProviderUrl(domain, email))
addAll(ispDbUrlProvider.getAutoconfigUrls(domain, email))
}
}
private fun createProviderUrl(domain: Domain, email: EmailAddress?): HttpUrl {
// After an MX lookup only the following provider URL is checked:
// https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email}
return HttpUrl.Builder()
.scheme("https")
.host("autoconfig.${domain.value}")
.addEncodedPathSegments("mail/config-v1.1.xml")
.apply {
if (email != null && config.includeEmailAddress) {
addQueryParameter("emailaddress", email.address)
}
}
.build()
}
}
internal fun createPostMxLookupAutoconfigUrlProvider(config: AutoconfigUrlConfig): AutoconfigUrlProvider {
return PostMxLookupAutoconfigUrlProvider(
ispDbUrlProvider = IspDbAutoconfigUrlProvider(),
config = config,
)
}

View file

@ -0,0 +1,54 @@
package app.k9mail.autodiscovery.autoconfig
import net.thunderbird.core.common.mail.EmailAddress
import net.thunderbird.core.common.net.Domain
import okhttp3.HttpUrl
internal class ProviderAutoconfigUrlProvider(private val config: AutoconfigUrlConfig) : AutoconfigUrlProvider {
override fun getAutoconfigUrls(domain: Domain, email: EmailAddress?): List<HttpUrl> {
return buildList {
add(createProviderUrl(domain, email, useHttps = true))
add(createDomainUrl(domain, email, useHttps = true))
if (!config.httpsOnly) {
add(createProviderUrl(domain, email, useHttps = false))
add(createDomainUrl(domain, email, useHttps = false))
}
}
}
private fun createProviderUrl(domain: Domain, email: EmailAddress?, useHttps: Boolean): HttpUrl {
// https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email}
// http://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email}
return HttpUrl.Builder()
.scheme(if (useHttps) "https" else "http")
.host("autoconfig.${domain.value}")
.addEncodedPathSegments("mail/config-v1.1.xml")
.apply {
if (email != null && config.includeEmailAddress) {
addQueryParameter("emailaddress", email.address)
}
}
.build()
}
private fun createDomainUrl(domain: Domain, email: EmailAddress?, useHttps: Boolean): HttpUrl {
// https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={email}
// http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={email}
return HttpUrl.Builder()
.scheme(if (useHttps) "https" else "http")
.host(domain.value)
.addEncodedPathSegments(".well-known/autoconfig/mail/config-v1.1.xml")
.apply {
if (email != null && config.includeEmailAddress) {
addQueryParameter("emailaddress", email.address)
}
}
.build()
}
}
data class AutoconfigUrlConfig(
val httpsOnly: Boolean,
val includeEmailAddress: Boolean,
)

View file

@ -0,0 +1,56 @@
package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.autoconfig.AutoconfigParserResult.ParserError
import app.k9mail.autodiscovery.autoconfig.AutoconfigParserResult.Settings
import app.k9mail.autodiscovery.autoconfig.HttpFetchResult.ErrorResponse
import app.k9mail.autodiscovery.autoconfig.HttpFetchResult.SuccessResponse
import java.io.IOException
import net.thunderbird.core.common.mail.EmailAddress
import net.thunderbird.core.logging.legacy.Log
import okhttp3.HttpUrl
internal class RealAutoconfigFetcher(
private val fetcher: HttpFetcher,
private val parser: SuspendableAutoconfigParser,
) : AutoconfigFetcher {
override suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult {
return try {
when (val fetchResult = fetcher.fetch(autoconfigUrl)) {
is SuccessResponse -> {
parseSettings(fetchResult, email, autoconfigUrl)
}
is ErrorResponse -> AutoDiscoveryResult.NoUsableSettingsFound
}
} catch (e: IOException) {
Log.d(e, "Error fetching Autoconfig from URL: %s", autoconfigUrl)
AutoDiscoveryResult.NetworkError(e)
}
}
private suspend fun parseSettings(
fetchResult: SuccessResponse,
email: EmailAddress,
autoconfigUrl: HttpUrl,
): AutoDiscoveryResult {
return try {
fetchResult.inputStream.use { inputStream ->
return when (val parserResult = parser.parseSettings(inputStream, email)) {
is Settings -> {
AutoDiscoveryResult.Settings(
incomingServerSettings = parserResult.incomingServerSettings.first(),
outgoingServerSettings = parserResult.outgoingServerSettings.first(),
isTrusted = fetchResult.isTrusted,
source = autoconfigUrl.toString(),
)
}
is ParserError -> AutoDiscoveryResult.NoUsableSettingsFound
}
}
} catch (e: AutoconfigParserException) {
Log.d(e, "Failed to parse config from URL: %s", autoconfigUrl)
AutoDiscoveryResult.NoUsableSettingsFound
}
}
}

View file

@ -0,0 +1,324 @@
package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.AuthenticationType
import app.k9mail.autodiscovery.api.AuthenticationType.OAuth2
import app.k9mail.autodiscovery.api.AuthenticationType.PasswordCleartext
import app.k9mail.autodiscovery.api.AuthenticationType.PasswordEncrypted
import app.k9mail.autodiscovery.api.ConnectionSecurity
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.IncomingServerSettings
import app.k9mail.autodiscovery.api.OutgoingServerSettings
import app.k9mail.autodiscovery.api.SmtpServerSettings
import java.io.InputStream
import java.io.InputStreamReader
import net.thunderbird.core.common.mail.EmailAddress
import net.thunderbird.core.common.net.HostNameUtils
import net.thunderbird.core.common.net.Hostname
import net.thunderbird.core.common.net.Port
import net.thunderbird.core.common.net.toHostname
import net.thunderbird.core.common.net.toPort
import net.thunderbird.core.logging.legacy.Log
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
private typealias ServerSettingsFactory<T> = (
hostname: Hostname,
port: Port,
connectionSecurity: ConnectionSecurity,
authenticationTypes: List<AuthenticationType>,
username: String,
) -> T
internal class RealAutoconfigParser : AutoconfigParser {
override fun parseSettings(inputStream: InputStream, email: EmailAddress): AutoconfigParserResult {
return try {
ClientConfigParser(inputStream, email).parse()
} catch (e: XmlPullParserException) {
AutoconfigParserResult.ParserError(error = AutoconfigParserException("Error parsing Autoconfig XML", e))
} catch (e: AutoconfigParserException) {
AutoconfigParserResult.ParserError(e)
}
}
}
@Suppress("TooManyFunctions")
private class ClientConfigParser(
private val inputStream: InputStream,
private val email: EmailAddress,
) {
private val localPart = email.localPart
private val domain = email.domain.normalized
private val pullParser: XmlPullParser = XmlPullParserFactory.newInstance().newPullParser().apply {
setInput(InputStreamReader(inputStream))
}
fun parse(): AutoconfigParserResult {
var result: AutoconfigParserResult? = null
do {
val eventType = pullParser.next()
if (eventType == XmlPullParser.START_TAG) {
when (pullParser.name) {
"clientConfig" -> {
result = parseClientConfig()
}
else -> skipElement()
}
}
} while (eventType != XmlPullParser.END_DOCUMENT)
if (result == null) {
parserError("Missing 'clientConfig' element")
}
return result
}
private fun parseClientConfig(): AutoconfigParserResult {
var result: AutoconfigParserResult? = null
readElement { eventType ->
if (eventType == XmlPullParser.START_TAG) {
when (pullParser.name) {
"emailProvider" -> {
result = parseEmailProvider()
}
else -> skipElement()
}
}
}
return result ?: parserError("Missing 'emailProvider' element")
}
private fun parseEmailProvider(): AutoconfigParserResult {
var domainFound = false
val incomingServerSettings = mutableListOf<IncomingServerSettings>()
val outgoingServerSettings = mutableListOf<OutgoingServerSettings>()
// The 'id' attribute is required (but not really used) by Thunderbird desktop.
val emailProviderId = pullParser.getAttributeValue(null, "id")
if (emailProviderId == null) {
parserError("Missing 'emailProvider.id' attribute")
} else if (!emailProviderId.isValidHostname()) {
parserError("Invalid 'emailProvider.id' attribute")
}
readElement { eventType ->
if (eventType == XmlPullParser.START_TAG) {
when (pullParser.name) {
"domain" -> {
val domain = readText().replaceVariables()
if (domain.isValidHostname()) {
domainFound = true
}
}
"incomingServer" -> {
parseServer("imap", ::createImapServerSettings)?.let { serverSettings ->
incomingServerSettings.add(serverSettings)
}
}
"outgoingServer" -> {
parseServer("smtp", ::createSmtpServerSettings)?.let { serverSettings ->
outgoingServerSettings.add(serverSettings)
}
}
else -> {
skipElement()
}
}
}
}
// Thunderbird desktop requires at least one valid 'domain' element.
if (!domainFound) {
parserError("Valid 'domain' element required")
}
if (incomingServerSettings.isEmpty()) {
parserError("No supported 'incomingServer' element found")
}
if (outgoingServerSettings.isEmpty()) {
parserError("No supported 'outgoingServer' element found")
}
return AutoconfigParserResult.Settings(
incomingServerSettings = incomingServerSettings,
outgoingServerSettings = outgoingServerSettings,
)
}
private fun <T> parseServer(
protocolType: String,
createServerSettings: ServerSettingsFactory<T>,
): T? {
val type = pullParser.getAttributeValue(null, "type")
if (type != protocolType) {
Log.d("Unsupported '%s[type]' value: '%s'", pullParser.name, type)
skipElement()
return null
}
var hostname: String? = null
var port: Int? = null
var userName: String? = null
val authenticationTypes = mutableListOf<AuthenticationType>()
var connectionSecurity: ConnectionSecurity? = null
readElement { eventType ->
if (eventType == XmlPullParser.START_TAG) {
when (pullParser.name) {
"hostname" -> hostname = readHostname()
"port" -> port = readPort()
"username" -> userName = readUsername()
"authentication" -> readAuthentication(authenticationTypes)
"socketType" -> connectionSecurity = readSocketType()
}
}
}
val finalHostname = hostname ?: parserError("Missing 'hostname' element")
val finalPort = port ?: parserError("Missing 'port' element")
val finalUserName = userName ?: parserError("Missing 'username' element")
val finalAuthenticationTypes = if (authenticationTypes.isNotEmpty()) {
authenticationTypes.toList()
} else {
parserError("No usable 'authentication' element found")
}
val finalConnectionSecurity = connectionSecurity ?: parserError("Missing 'socketType' element")
return createServerSettings(
finalHostname.toHostname(),
finalPort.toPort(),
finalConnectionSecurity,
finalAuthenticationTypes,
finalUserName,
)
}
private fun readHostname(): String {
val hostNameText = readText()
val hostName = hostNameText.replaceVariables()
return hostName.takeIf { it.isValidHostname() }
?: parserError("Invalid 'hostname' value: '$hostNameText'")
}
private fun readPort(): Int {
val portText = readText()
return portText.toIntOrNull()?.takeIf { it.isValidPort() }
?: parserError("Invalid 'port' value: '$portText'")
}
private fun readUsername(): String = readText().replaceVariables()
private fun readAuthentication(authenticationTypes: MutableList<AuthenticationType>) {
val authenticationType = readText().toAuthenticationType() ?: return
authenticationTypes.add(authenticationType)
}
private fun readSocketType() = readText().toConnectionSecurity()
private fun String.toAuthenticationType(): AuthenticationType? {
return when (this) {
"OAuth2" -> OAuth2
"password-cleartext" -> PasswordCleartext
"password-encrypted" -> PasswordEncrypted
else -> {
Log.d("Ignoring unknown 'authentication' value '$this'")
null
}
}
}
private fun String.toConnectionSecurity(): ConnectionSecurity {
return when (this) {
"SSL" -> TLS
"STARTTLS" -> StartTLS
else -> parserError("Unknown 'socketType' value: '$this'")
}
}
private fun readElement(block: (Int) -> Unit) {
require(pullParser.eventType == XmlPullParser.START_TAG)
val tagName = pullParser.name
val depth = pullParser.depth
while (true) {
when (val eventType = pullParser.next()) {
XmlPullParser.END_DOCUMENT -> {
parserError("End of document reached while reading element '$tagName'")
}
XmlPullParser.END_TAG -> {
if (pullParser.name == tagName && pullParser.depth == depth) return
}
else -> {
block(eventType)
}
}
}
}
private fun readText(): String {
var text = ""
readElement { eventType ->
when (eventType) {
XmlPullParser.TEXT -> {
text = pullParser.text
}
else -> {
parserError("Expected text, but got ${XmlPullParser.TYPES[eventType]}")
}
}
}
return text
}
private fun skipElement() {
Log.d("Skipping element '%s'", pullParser.name)
readElement { /* Do nothing */ }
}
private fun parserError(message: String): Nothing {
throw AutoconfigParserException(message)
}
private fun String.isValidHostname(): Boolean {
val cleanUpHostName = HostNameUtils.cleanUpHostName(this)
return HostNameUtils.isLegalHostNameOrIP(cleanUpHostName) != null
}
@Suppress("MagicNumber")
private fun Int.isValidPort() = this in 0..65535
private fun String.replaceVariables(): String {
return replace("%EMAILDOMAIN%", domain)
.replace("%EMAILLOCALPART%", localPart)
.replace("%EMAILADDRESS%", email.address)
}
private fun createImapServerSettings(
hostname: Hostname,
port: Port,
connectionSecurity: ConnectionSecurity,
authenticationTypes: List<AuthenticationType>,
username: String,
): ImapServerSettings {
return ImapServerSettings(hostname, port, connectionSecurity, authenticationTypes, username)
}
private fun createSmtpServerSettings(
hostname: Hostname,
port: Port,
connectionSecurity: ConnectionSecurity,
authenticationTypes: List<AuthenticationType>,
username: String,
): SmtpServerSettings {
return SmtpServerSettings(hostname, port, connectionSecurity, authenticationTypes, username)
}
}

View file

@ -0,0 +1,26 @@
package app.k9mail.autodiscovery.autoconfig
import net.thunderbird.core.common.net.Domain
import net.thunderbird.core.common.net.toDomain
internal class RealSubDomainExtractor(private val baseDomainExtractor: BaseDomainExtractor) : SubDomainExtractor {
@Suppress("ReturnCount")
override fun extractSubDomain(domain: Domain): Domain? {
val baseDomain = baseDomainExtractor.extractBaseDomain(domain)
if (baseDomain == domain) {
// The domain doesn't have a sub domain.
return null
}
val baseDomainString = baseDomain.value
val domainPrefix = domain.value.removeSuffix(".$baseDomainString")
val index = domainPrefix.indexOf('.')
if (index == -1) {
// The prefix is the sub domain. When we remove it only the base domain remains.
return baseDomain
}
val prefixWithoutFirstLabel = domainPrefix.substring(index + 1)
return "$prefixWithoutFirstLabel.$baseDomainString".toDomain()
}
}

View file

@ -0,0 +1,12 @@
package app.k9mail.autodiscovery.autoconfig
import net.thunderbird.core.common.net.Domain
/**
* Extract the sub domain from a host name.
*
* An implementation needs to respect the [Public Suffix List](https://publicsuffix.org/).
*/
internal interface SubDomainExtractor {
fun extractSubDomain(domain: Domain): Domain?
}

View file

@ -0,0 +1,14 @@
package app.k9mail.autodiscovery.autoconfig
import java.io.InputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import net.thunderbird.core.common.mail.EmailAddress
internal class SuspendableAutoconfigParser(private val autoconfigParser: AutoconfigParser) {
suspend fun parseSettings(inputStream: InputStream, email: EmailAddress): AutoconfigParserResult {
return runInterruptible(Dispatchers.IO) {
autoconfigParser.parseSettings(inputStream, email)
}
}
}

View file

@ -0,0 +1,13 @@
package app.k9mail.autodiscovery.autoconfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import net.thunderbird.core.common.net.Domain
internal class SuspendableMxResolver(private val mxResolver: MxResolver) {
suspend fun lookup(domain: Domain): MxLookupResult {
return runInterruptible(Dispatchers.IO) {
mxResolver.lookup(domain)
}
}
}

View file

@ -0,0 +1,69 @@
package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.autoconfig.MockAutoconfigFetcher.Companion.RESULT_ONE
import app.k9mail.autodiscovery.autoconfig.MockAutoconfigFetcher.Companion.RESULT_TWO
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.extracting
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import kotlin.test.Test
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.common.mail.toUserEmailAddress
import net.thunderbird.core.common.net.toDomain
import okhttp3.HttpUrl.Companion.toHttpUrl
private val IRRELEVANT_EMAIL_ADDRESS = "irrelevant@domain.example".toUserEmailAddress()
class AutoconfigDiscoveryTest {
private val urlProvider = MockAutoconfigUrlProvider()
private val autoconfigFetcher = MockAutoconfigFetcher()
private val discovery = AutoconfigDiscovery(urlProvider, autoconfigFetcher)
@Test
fun `AutoconfigFetcher and AutoconfigParser should only be called when AutoDiscoveryRunnable is run`() = runTest {
val emailAddress = "user@domain.example".toUserEmailAddress()
val autoconfigUrl = "https://autoconfig.domain.invalid/mail/config-v1.1.xml".toHttpUrl()
urlProvider.addResult(listOf(autoconfigUrl))
autoconfigFetcher.addResult(RESULT_ONE)
val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress)
assertThat(autoDiscoveryRunnables).hasSize(1)
assertThat(urlProvider.callArguments).containsExactly("domain.example".toDomain() to emailAddress)
assertThat(autoconfigFetcher.callCount).isEqualTo(0)
val discoveryResult = autoDiscoveryRunnables.first().run()
assertThat(autoconfigFetcher.callArguments).containsExactly(autoconfigUrl to emailAddress)
assertThat(discoveryResult).isEqualTo(RESULT_ONE)
}
@Test
fun `Two Autoconfig URLs should return two AutoDiscoveryRunnables`() = runTest {
val urlOne = "https://autoconfig.domain1.invalid/mail/config-v1.1.xml".toHttpUrl()
val urlTwo = "https://autoconfig.domain2.invalid/mail/config-v1.1.xml".toHttpUrl()
urlProvider.addResult(listOf(urlOne, urlTwo))
autoconfigFetcher.apply {
addResult(RESULT_ONE)
addResult(RESULT_TWO)
}
val autoDiscoveryRunnables = discovery.initDiscovery(IRRELEVANT_EMAIL_ADDRESS)
assertThat(autoDiscoveryRunnables).hasSize(2)
val discoveryResultOne = autoDiscoveryRunnables[0].run()
assertThat(autoconfigFetcher.callArguments).extracting { it.first }.containsExactly(urlOne)
assertThat(discoveryResultOne).isEqualTo(RESULT_ONE)
autoconfigFetcher.callArguments.clear()
val discoveryResultTwo = autoDiscoveryRunnables[1].run()
assertThat(autoconfigFetcher.callArguments).extracting { it.first }.containsExactly(urlTwo)
assertThat(discoveryResultTwo).isEqualTo(RESULT_TWO)
}
}

View file

@ -0,0 +1,22 @@
package app.k9mail.autodiscovery.autoconfig
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.extracting
import net.thunderbird.core.common.net.toDomain
import org.junit.Test
class IspDbAutoconfigUrlProviderTest {
private val urlProvider = IspDbAutoconfigUrlProvider()
@Test
fun `getAutoconfigUrls with ASCII email address`() {
val domain = "domain.example".toDomain()
val autoconfigUrls = urlProvider.getAutoconfigUrls(domain)
assertThat(autoconfigUrls).extracting { it.toString() }.containsExactly(
"https://autoconfig.thunderbird.net/v1.1/domain.example",
)
}
}

View file

@ -0,0 +1,58 @@
package app.k9mail.autodiscovery.autoconfig
import assertk.all
import assertk.assertThat
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.extracting
import assertk.assertions.index
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import kotlin.test.Ignore
import kotlin.test.Test
import net.thunderbird.core.common.net.toDomain
class MiniDnsMxResolverTest {
private val resolver = MiniDnsMxResolver()
@Test
@Ignore("Requires internet")
fun `MX lookup for known domain`() {
val domain = "thunderbird.net".toDomain()
val result = resolver.lookup(domain)
assertThat(result.mxNames).extracting { it.value }.all {
index(0).isEqualTo("aspmx.l.google.com")
containsExactlyInAnyOrder(
"aspmx.l.google.com",
"alt1.aspmx.l.google.com",
"alt2.aspmx.l.google.com",
"alt4.aspmx.l.google.com",
"alt3.aspmx.l.google.com",
)
}
}
@Test
@Ignore("Requires internet")
fun `MX lookup for known domain using DNSSEC`() {
val domain = "posteo.de".toDomain()
val result = resolver.lookup(domain)
assertThat(result.isTrusted).isTrue()
}
@Test
@Ignore("Requires internet")
fun `MX lookup for non-existent domain`() {
val domain = "test.invalid".toDomain()
val result = resolver.lookup(domain)
assertThat(result.mxNames).isEmpty()
assertThat(result.isTrusted).isFalse()
}
}

View file

@ -0,0 +1,77 @@
package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.AuthenticationType.PasswordCleartext
import app.k9mail.autodiscovery.api.AuthenticationType.PasswordEncrypted
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
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 net.thunderbird.core.common.mail.EmailAddress
import net.thunderbird.core.common.net.toHostname
import net.thunderbird.core.common.net.toPort
import okhttp3.HttpUrl
internal class MockAutoconfigFetcher : AutoconfigFetcher {
val callArguments = mutableListOf<Pair<HttpUrl, EmailAddress>>()
val callCount: Int
get() = callArguments.size
val urls: List<String>
get() = callArguments.map { (url, _) -> url.toString() }
private val results = mutableListOf<AutoDiscoveryResult>()
fun addResult(discoveryResult: AutoDiscoveryResult) {
results.add(discoveryResult)
}
override suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult {
callArguments.add(autoconfigUrl to email)
check(results.isNotEmpty()) {
"MockAutoconfigFetcher.fetchAutoconfig($autoconfigUrl) called but no result provided"
}
return results.removeAt(0)
}
companion object {
val RESULT_ONE = AutoDiscoveryResult.Settings(
incomingServerSettings = ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 993.toPort(),
connectionSecurity = TLS,
authenticationTypes = listOf(PasswordCleartext),
username = "irrelevant@domain.example",
),
outgoingServerSettings = SmtpServerSettings(
hostname = "smtp.domain.example".toHostname(),
port = 465.toPort(),
connectionSecurity = TLS,
authenticationTypes = listOf(PasswordCleartext),
username = "irrelevant@domain.example",
),
isTrusted = true,
source = "result 1",
)
val RESULT_TWO = AutoDiscoveryResult.Settings(
incomingServerSettings = ImapServerSettings(
hostname = "imap.company.example".toHostname(),
port = 143.toPort(),
connectionSecurity = StartTLS,
authenticationTypes = listOf(PasswordEncrypted),
username = "irrelevant@company.example",
),
outgoingServerSettings = SmtpServerSettings(
hostname = "smtp.company.example".toHostname(),
port = 587.toPort(),
connectionSecurity = StartTLS,
authenticationTypes = listOf(PasswordEncrypted),
username = "irrelevant@company.example",
),
isTrusted = true,
source = "result 2",
)
}
}

View file

@ -0,0 +1,25 @@
package app.k9mail.autodiscovery.autoconfig
import net.thunderbird.core.common.mail.EmailAddress
import net.thunderbird.core.common.net.Domain
import okhttp3.HttpUrl
internal class MockAutoconfigUrlProvider : AutoconfigUrlProvider {
val callArguments = mutableListOf<Pair<Domain, EmailAddress?>>()
val callCount: Int
get() = callArguments.size
private val results = mutableListOf<List<HttpUrl>>()
fun addResult(urls: List<HttpUrl>) {
results.add(urls)
}
override fun getAutoconfigUrls(domain: Domain, email: EmailAddress?): List<HttpUrl> {
callArguments.add(domain to email)
check(results.isNotEmpty()) { "getAutoconfigUrls($domain, $email) called but no result provided" }
return results.removeAt(0)
}
}

View file

@ -0,0 +1,27 @@
package app.k9mail.autodiscovery.autoconfig
import net.thunderbird.core.common.net.Domain
class MockMxResolver : MxResolver {
val callArguments = mutableListOf<Domain>()
val callCount: Int
get() = callArguments.size
private val results = mutableListOf<MxLookupResult>()
fun addResult(domain: Domain, isTrusted: Boolean = true) {
results.add(MxLookupResult(mxNames = listOf(domain), isTrusted = isTrusted))
}
fun addResult(domains: List<Domain>) {
results.add(MxLookupResult(mxNames = domains, isTrusted = true))
}
override fun lookup(domain: Domain): MxLookupResult {
callArguments.add(domain)
check(results.isNotEmpty()) { "lookup($domain) called but no result provided" }
return results.removeAt(0)
}
}

View file

@ -0,0 +1,151 @@
package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NoUsableSettingsFound
import app.k9mail.autodiscovery.autoconfig.MockAutoconfigFetcher.Companion.RESULT_ONE
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import kotlin.test.Test
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.common.mail.toUserEmailAddress
import net.thunderbird.core.common.net.toDomain
class MxLookupAutoconfigDiscoveryTest {
private val mxResolver = MockMxResolver()
private val baseDomainExtractor = OkHttpBaseDomainExtractor()
private val urlProvider = createPostMxLookupAutoconfigUrlProvider(
AutoconfigUrlConfig(
httpsOnly = true,
includeEmailAddress = true,
),
)
private val autoconfigFetcher = MockAutoconfigFetcher()
private val discovery = MxLookupAutoconfigDiscovery(
mxResolver = SuspendableMxResolver(mxResolver),
baseDomainExtractor = baseDomainExtractor,
subDomainExtractor = RealSubDomainExtractor(baseDomainExtractor),
urlProvider = urlProvider,
autoconfigFetcher = autoconfigFetcher,
)
@Test
fun `result from email provider should be used if available`() = runTest {
val emailAddress = "user@company.example".toUserEmailAddress()
mxResolver.addResult("mx.emailprovider.example".toDomain())
autoconfigFetcher.apply {
addResult(RESULT_ONE)
}
val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress)
assertThat(autoDiscoveryRunnables).hasSize(1)
assertThat(mxResolver.callCount).isEqualTo(0)
assertThat(autoconfigFetcher.callCount).isEqualTo(0)
val discoveryResult = autoDiscoveryRunnables.first().run()
assertThat(autoconfigFetcher.urls).containsExactly(
"https://autoconfig.emailprovider.example/mail/config-v1.1.xml?emailaddress=user%40company.example",
)
assertThat(discoveryResult).isEqualTo(RESULT_ONE)
}
@Test
fun `result from ISPDB should be used if config is not available at email provider`() = runTest {
val emailAddress = "user@company.example".toUserEmailAddress()
mxResolver.addResult("mx.emailprovider.example".toDomain())
autoconfigFetcher.apply {
addResult(NoUsableSettingsFound)
addResult(RESULT_ONE)
}
val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress)
assertThat(autoDiscoveryRunnables).hasSize(1)
assertThat(mxResolver.callCount).isEqualTo(0)
assertThat(autoconfigFetcher.callCount).isEqualTo(0)
val discoveryResult = autoDiscoveryRunnables.first().run()
assertThat(autoconfigFetcher.urls).containsExactly(
"https://autoconfig.emailprovider.example/mail/config-v1.1.xml?emailaddress=user%40company.example",
"https://autoconfig.thunderbird.net/v1.1/emailprovider.example",
)
assertThat(discoveryResult).isEqualTo(RESULT_ONE)
}
@Test
fun `base domain and subdomain should be extracted from MX host if possible`() = runTest {
val emailAddress = "user@company.example".toUserEmailAddress()
mxResolver.addResult("mx.something.emailprovider.example".toDomain())
autoconfigFetcher.apply {
addResult(NoUsableSettingsFound)
addResult(NoUsableSettingsFound)
addResult(NoUsableSettingsFound)
addResult(NoUsableSettingsFound)
}
val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress)
val discoveryResult = autoDiscoveryRunnables.first().run()
assertThat(autoconfigFetcher.urls).containsExactly(
"https://autoconfig.something.emailprovider.example/mail/config-v1.1.xml" +
"?emailaddress=user%40company.example",
"https://autoconfig.thunderbird.net/v1.1/something.emailprovider.example",
"https://autoconfig.emailprovider.example/mail/config-v1.1.xml?emailaddress=user%40company.example",
"https://autoconfig.thunderbird.net/v1.1/emailprovider.example",
)
assertThat(discoveryResult).isEqualTo(NoUsableSettingsFound)
}
@Test
fun `skip Autoconfig lookup when MX lookup does not return a result`() = runTest {
val emailAddress = "user@company.example".toUserEmailAddress()
mxResolver.addResult(emptyList())
val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress)
val discoveryResult = autoDiscoveryRunnables.first().run()
assertThat(mxResolver.callCount).isEqualTo(1)
assertThat(autoconfigFetcher.callCount).isEqualTo(0)
assertThat(discoveryResult).isEqualTo(NoUsableSettingsFound)
}
@Test
fun `skip Autoconfig lookup when base domain of MX record is email domain`() = runTest {
val emailAddress = "user@company.example".toUserEmailAddress()
mxResolver.addResult("mx.company.example".toDomain())
val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress)
val discoveryResult = autoDiscoveryRunnables.first().run()
assertThat(mxResolver.callCount).isEqualTo(1)
assertThat(autoconfigFetcher.callCount).isEqualTo(0)
assertThat(discoveryResult).isEqualTo(NoUsableSettingsFound)
}
@Test
fun `isTrusted should be false when MxLookupResult_isTrusted is false`() = runTest {
val emailAddress = "user@company.example".toUserEmailAddress()
mxResolver.addResult("mx.emailprovider.example".toDomain(), isTrusted = false)
autoconfigFetcher.addResult(RESULT_ONE.copy(isTrusted = true))
val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress)
val discoveryResult = autoDiscoveryRunnables.first().run()
assertThat(discoveryResult).isEqualTo(RESULT_ONE.copy(isTrusted = false))
}
@Test
fun `isTrusted should be false when AutoDiscoveryResult_isTrusted from AutoconfigFetcher is false`() = runTest {
val emailAddress = "user@company.example".toUserEmailAddress()
mxResolver.addResult("mx.emailprovider.example".toDomain(), isTrusted = true)
autoconfigFetcher.addResult(RESULT_ONE.copy(isTrusted = false))
val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress)
val discoveryResult = autoDiscoveryRunnables.first().run()
assertThat(discoveryResult).isEqualTo(RESULT_ONE.copy(isTrusted = false))
}
}

View file

@ -0,0 +1,46 @@
package app.k9mail.autodiscovery.autoconfig
import assertk.assertThat
import assertk.assertions.isEqualTo
import net.thunderbird.core.common.net.toDomain
import org.junit.Test
class OkHttpBaseDomainExtractorTest {
private val baseDomainExtractor = OkHttpBaseDomainExtractor()
@Test
fun `basic domain`() {
val domain = "domain.example".toDomain()
val result = baseDomainExtractor.extractBaseDomain(domain)
assertThat(result).isEqualTo(domain)
}
@Test
fun `basic subdomain`() {
val domain = "subdomain.domain.example".toDomain()
val result = baseDomainExtractor.extractBaseDomain(domain)
assertThat(result).isEqualTo("domain.example".toDomain())
}
@Test
fun `domain with public suffix`() {
val domain = "example.co.uk".toDomain()
val result = baseDomainExtractor.extractBaseDomain(domain)
assertThat(result).isEqualTo(domain)
}
@Test
fun `subdomain with public suffix`() {
val domain = "subdomain.example.co.uk".toDomain()
val result = baseDomainExtractor.extractBaseDomain(domain)
assertThat(result).isEqualTo("example.co.uk".toDomain())
}
}

View file

@ -0,0 +1,48 @@
package app.k9mail.autodiscovery.autoconfig
import assertk.all
import assertk.assertFailure
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.prop
import java.net.UnknownHostException
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Test
class OkHttpFetcherTest {
private val fetcher = OkHttpFetcher(OkHttpClient.Builder().build())
@Test
fun shouldHandleNonexistentUrl() = runTest {
val nonExistentUrl =
"https://autoconfig.domain.invalid/mail/config-v1.1.xml?emailaddress=test%40domain.example".toHttpUrl()
assertFailure {
fetcher.fetch(nonExistentUrl)
}.isInstanceOf<UnknownHostException>()
}
@Test
fun shouldHandleEmptyResponse() = runTest {
val server = MockWebServer().apply {
this.enqueue(
MockResponse()
.setBody("")
.setResponseCode(204),
)
start()
}
val url = server.url("/empty/")
val result = fetcher.fetch(url)
assertThat(result).isInstanceOf<HttpFetchResult.SuccessResponse>().all {
prop(HttpFetchResult.SuccessResponse::inputStream).transform { it.available() }.isEqualTo(0)
}
}
}

View file

@ -0,0 +1,42 @@
package app.k9mail.autodiscovery.autoconfig
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.extracting
import net.thunderbird.core.common.mail.toEmailAddressOrThrow
import net.thunderbird.core.common.net.toDomain
import org.junit.Test
class PostMxLookupAutoconfigUrlProviderTest {
@Test
fun `getAutoconfigUrls with including email address`() {
val urlProvider = createPostMxLookupAutoconfigUrlProvider(
AutoconfigUrlConfig(httpsOnly = false, includeEmailAddress = true),
)
val domain = "domain.example".toDomain()
val emailAddress = "test@domain.example".toEmailAddressOrThrow()
val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, emailAddress)
assertThat(autoconfigUrls).extracting { it.toString() }.containsExactly(
"https://autoconfig.domain.example/mail/config-v1.1.xml?emailaddress=test%40domain.example",
"https://autoconfig.thunderbird.net/v1.1/domain.example",
)
}
@Test
fun `getAutoconfigUrls without including email address`() {
val urlProvider = createPostMxLookupAutoconfigUrlProvider(
AutoconfigUrlConfig(httpsOnly = false, includeEmailAddress = false),
)
val domain = "domain.example".toDomain()
val emailAddress = "test@domain.example".toEmailAddressOrThrow()
val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, emailAddress)
assertThat(autoconfigUrls).extracting { it.toString() }.containsExactly(
"https://autoconfig.domain.example/mail/config-v1.1.xml",
"https://autoconfig.thunderbird.net/v1.1/domain.example",
)
}
}

View file

@ -0,0 +1,88 @@
package app.k9mail.autodiscovery.autoconfig
import assertk.assertThat
import assertk.assertions.containsExactly
import net.thunderbird.core.common.mail.toUserEmailAddress
import net.thunderbird.core.common.net.toDomain
import org.junit.Test
class ProviderAutoconfigUrlProviderTest {
private val domain = "domain.example".toDomain()
private val email = "test@domain.example".toUserEmailAddress()
@Test
fun `getAutoconfigUrls with http allowed and email address included`() {
val urlProvider = ProviderAutoconfigUrlProvider(
AutoconfigUrlConfig(httpsOnly = false, includeEmailAddress = true),
)
val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, email)
assertThat(autoconfigUrls.map { it.toString() }).containsExactly(
"https://autoconfig.domain.example/mail/config-v1.1.xml?emailaddress=test%40domain.example",
"https://domain.example/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=test%40domain.example",
"http://autoconfig.domain.example/mail/config-v1.1.xml?emailaddress=test%40domain.example",
"http://domain.example/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=test%40domain.example",
)
}
@Test
fun `getAutoconfigUrls with only https and email address included`() {
val urlProvider = ProviderAutoconfigUrlProvider(
AutoconfigUrlConfig(httpsOnly = true, includeEmailAddress = true),
)
val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, email)
assertThat(autoconfigUrls.map { it.toString() }).containsExactly(
"https://autoconfig.domain.example/mail/config-v1.1.xml?emailaddress=test%40domain.example",
"https://domain.example/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=test%40domain.example",
)
}
@Test
fun `getAutoconfigUrls with only https and email address not included`() {
val urlProvider = ProviderAutoconfigUrlProvider(
AutoconfigUrlConfig(httpsOnly = true, includeEmailAddress = false),
)
val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, email)
assertThat(autoconfigUrls.map { it.toString() }).containsExactly(
"https://autoconfig.domain.example/mail/config-v1.1.xml",
"https://domain.example/.well-known/autoconfig/mail/config-v1.1.xml",
)
}
@Test
fun `getAutoconfigUrls with http allowed and email address not included`() {
val urlProvider = ProviderAutoconfigUrlProvider(
AutoconfigUrlConfig(httpsOnly = false, includeEmailAddress = false),
)
val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, email)
assertThat(autoconfigUrls.map { it.toString() }).containsExactly(
"https://autoconfig.domain.example/mail/config-v1.1.xml",
"https://domain.example/.well-known/autoconfig/mail/config-v1.1.xml",
"http://autoconfig.domain.example/mail/config-v1.1.xml",
"http://domain.example/.well-known/autoconfig/mail/config-v1.1.xml",
)
}
@Test
fun `getAutoconfigUrls with http allowed and email address included, but none provided`() {
val urlProvider = ProviderAutoconfigUrlProvider(
AutoconfigUrlConfig(httpsOnly = false, includeEmailAddress = true),
)
val autoconfigUrls = urlProvider.getAutoconfigUrls(domain)
assertThat(autoconfigUrls.map { it.toString() }).containsExactly(
"https://autoconfig.domain.example/mail/config-v1.1.xml",
"https://domain.example/.well-known/autoconfig/mail/config-v1.1.xml",
"http://autoconfig.domain.example/mail/config-v1.1.xml",
"http://domain.example/.well-known/autoconfig/mail/config-v1.1.xml",
)
}
}

View file

@ -0,0 +1,651 @@
package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.AuthenticationType.OAuth2
import app.k9mail.autodiscovery.api.AuthenticationType.PasswordCleartext
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 app.k9mail.autodiscovery.autoconfig.AutoconfigParserResult.ParserError
import app.k9mail.autodiscovery.autoconfig.AutoconfigParserResult.Settings
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.hasMessage
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.prop
import java.io.InputStream
import net.thunderbird.core.common.mail.toUserEmailAddress
import net.thunderbird.core.common.net.toHostname
import net.thunderbird.core.common.net.toPort
import net.thunderbird.core.logging.legacy.Log
import net.thunderbird.core.logging.testing.TestLogger
import org.intellij.lang.annotations.Language
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.parser.Parser
import org.junit.Before
import org.junit.Test
private const val PRINT_MODIFIED_XML = false
class RealAutoconfigParserTest {
private val parser = RealAutoconfigParser()
@Language("XML")
private val minimalConfig =
"""
<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="domain.example">
<domain>domain.example</domain>
<incomingServer type="imap">
<hostname>imap.domain.example</hostname>
<port>993</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.domain.example</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
</emailProvider>
</clientConfig>
""".trimIndent()
@Language("XML")
private val additionalIncomingServer =
"""
<incomingServer type="imap">
<hostname>imap.domain.example</hostname>
<port>143</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
""".trimIndent()
@Language("XML")
private val additionalOutgoingServer =
"""
<outgoingServer type="smtp">
<hostname>smtp.domain.example</hostname>
<port>465</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
""".trimIndent()
private val irrelevantEmailAddress = "irrelevant@domain.example".toUserEmailAddress()
@Before
fun setUp() {
Log.logger = TestLogger()
}
@Test
fun `minimal data`() {
val inputStream = minimalConfig.byteInputStream()
val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress())
assertThat(result).isNotNull().isEqualTo(
Settings(
incomingServerSettings = listOf(
ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 993.toPort(),
connectionSecurity = TLS,
authenticationTypes = listOf(PasswordCleartext),
username = "user@domain.example",
),
),
outgoingServerSettings = listOf(
SmtpServerSettings(
hostname = "smtp.domain.example".toHostname(),
port = 587.toPort(),
connectionSecurity = StartTLS,
authenticationTypes = listOf(PasswordCleartext),
username = "user@domain.example",
),
),
),
)
}
@Test
fun `real-world data`() {
val inputStream = javaClass.getResourceAsStream("/2022-11-19-googlemail.com.xml")!!
val result = parser.parseSettings(inputStream, email = "test@gmail.com".toUserEmailAddress())
assertThat(result).isNotNull().isEqualTo(
Settings(
incomingServerSettings = listOf(
ImapServerSettings(
hostname = "imap.gmail.com".toHostname(),
port = 993.toPort(),
connectionSecurity = TLS,
authenticationTypes = listOf(OAuth2, PasswordCleartext),
username = "test@gmail.com",
),
),
outgoingServerSettings = listOf(
SmtpServerSettings(
hostname = "smtp.gmail.com".toHostname(),
port = 465.toPort(),
connectionSecurity = TLS,
authenticationTypes = listOf(OAuth2, PasswordCleartext),
username = "test@gmail.com",
),
),
),
)
}
@Test
fun `multiple incomingServer and outgoingServer elements`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer").insertBefore(additionalIncomingServer)
element("outgoingServer").insertBefore(additionalOutgoingServer)
}
val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress())
assertThat(result).isNotNull().isEqualTo(
Settings(
incomingServerSettings = listOf(
ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 143.toPort(),
connectionSecurity = StartTLS,
authenticationTypes = listOf(PasswordCleartext),
username = "user@domain.example",
),
ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 993.toPort(),
connectionSecurity = TLS,
authenticationTypes = listOf(PasswordCleartext),
username = "user@domain.example",
),
),
outgoingServerSettings = listOf(
SmtpServerSettings(
hostname = "smtp.domain.example".toHostname(),
port = 465.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",
),
),
),
)
}
@Test
fun `replace variables`() {
val inputStream = minimalConfig.withModifications {
element("domain").text("%EMAILDOMAIN%")
element("incomingServer > hostname").text("%EMAILLOCALPART%.domain.example")
element("outgoingServer > hostname").text("%EMAILLOCALPART%.outgoing.domain.example")
element("outgoingServer > username").text("%EMAILDOMAIN%")
}
val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress())
assertThat(result).isNotNull().isEqualTo(
Settings(
incomingServerSettings = listOf(
ImapServerSettings(
hostname = "user.domain.example".toHostname(),
port = 993.toPort(),
connectionSecurity = TLS,
authenticationTypes = listOf(PasswordCleartext),
username = "user@domain.example",
),
),
outgoingServerSettings = listOf(
SmtpServerSettings(
hostname = "user.outgoing.domain.example".toHostname(),
port = 587.toPort(),
connectionSecurity = StartTLS,
authenticationTypes = listOf(PasswordCleartext),
username = "domain.example",
),
),
),
)
}
@Test
fun `data with comments`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > hostname").prepend("<!-- comment -->")
element("incomingServer > port").prepend("<!-- comment -->")
element("incomingServer > socketType").prepend("<!-- comment -->")
element("incomingServer > authentication").prepend("<!-- comment -->")
element("incomingServer > username").prepend("<!-- comment -->")
}
val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress())
assertThat(result).isInstanceOf<Settings>()
.prop(Settings::incomingServerSettings).containsExactly(
ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 993.toPort(),
connectionSecurity = TLS,
authenticationTypes = listOf(PasswordCleartext),
username = "user@domain.example",
),
)
}
@Test
fun `ignore unsupported 'incomingServer' type`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer").insertBefore("""<incomingServer type="smtp"/>""")
}
val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress())
assertThat(result).isInstanceOf<Settings>()
.prop(Settings::incomingServerSettings).containsExactly(
ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 993.toPort(),
connectionSecurity = TLS,
authenticationTypes = listOf(PasswordCleartext),
username = "user@domain.example",
),
)
}
@Test
fun `ignore unsupported 'outgoingServer' type`() {
val inputStream = minimalConfig.withModifications {
element("outgoingServer").insertBefore("""<outgoingServer type="imap"/>""")
}
val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress())
assertThat(result).isInstanceOf<Settings>()
.prop(Settings::outgoingServerSettings).containsExactly(
SmtpServerSettings(
hostname = "smtp.domain.example".toHostname(),
port = 587.toPort(),
connectionSecurity = StartTLS,
authenticationTypes = listOf(PasswordCleartext),
username = "user@domain.example",
),
)
}
@Test
fun `empty authentication element should be ignored`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > authentication").insertBefore("<authentication></authentication>")
}
val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress())
assertThat(result).isInstanceOf<Settings>()
.prop(Settings::incomingServerSettings).containsExactly(
ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 993.toPort(),
connectionSecurity = TLS,
authenticationTypes = listOf(PasswordCleartext),
username = "user@domain.example",
),
)
}
@Test
fun `config with missing 'emailProvider id' attribute should throw`() {
val inputStream = minimalConfig.withModifications {
element("emailProvider").removeAttr("id")
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Missing 'emailProvider.id' attribute")
}
@Test
fun `config with invalid 'emailProvider id' attribute should throw`() {
val inputStream = minimalConfig.withModifications {
element("emailProvider").attr("id", "-23")
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Invalid 'emailProvider.id' attribute")
}
@Test
fun `config with missing domain element should throw`() {
val inputStream = minimalConfig.withModifications {
element("emailProvider > domain").remove()
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Valid 'domain' element required")
}
@Test
fun `config with only invalid domain elements should throw`() {
val inputStream = minimalConfig.withModifications {
element("emailProvider > domain").text("-invalid")
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Valid 'domain' element required")
}
@Test
fun `config with missing 'incomingServer' element should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer").remove()
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("No supported 'incomingServer' element found")
}
@Test
fun `config with missing 'outgoingServer' element should throw`() {
val inputStream = minimalConfig.withModifications {
element("outgoingServer").remove()
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("No supported 'outgoingServer' element found")
}
@Test
fun `incomingServer with missing hostname should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > hostname").remove()
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Missing 'hostname' element")
}
@Test
fun `incomingServer with invalid hostname should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > hostname").text("in valid")
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Invalid 'hostname' value: 'in valid'")
}
@Test
fun `incomingServer with missing port should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > port").remove()
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Missing 'port' element")
}
@Test
fun `incomingServer with missing socketType should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > socketType").remove()
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Missing 'socketType' element")
}
@Test
fun `incomingServer with missing authentication should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > authentication").remove()
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("No usable 'authentication' element found")
}
@Test
fun `incomingServer with missing username should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > username").remove()
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Missing 'username' element")
}
@Test
fun `incomingServer with invalid port should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > port").text("invalid")
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Invalid 'port' value: 'invalid'")
}
@Test
fun `incomingServer with out of range port number should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > port").text("100000")
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Invalid 'port' value: '100000'")
}
@Test
fun `incomingServer with unknown socketType should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > socketType").text("TLS")
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Unknown 'socketType' value: 'TLS'")
}
@Test
fun `element found when expecting text should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > hostname").html("imap.domain.example<element/>")
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Expected text, but got START_TAG")
}
@Test
fun `ignore 'incomingServer' and 'outgoingServer' inside wrong element`() {
val inputStream = minimalConfig.withModifications {
element("emailProvider").tagName("madeUpTag")
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Missing 'emailProvider' element")
}
@Test
fun `ignore 'incomingServer' inside unsupported 'incomingServer' element`() {
val inputStream = minimalConfig.withModifications {
val incomingServer = element("incomingServer")
val incomingServerXml = incomingServer.outerHtml()
incomingServer.attr("type", "unsupported")
incomingServer.html(incomingServerXml)
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("No supported 'incomingServer' element found")
}
@Test
fun `ignore 'outgoingServer' inside unsupported 'outgoingServer' element`() {
val inputStream = minimalConfig.withModifications {
val outgoingServer = element("outgoingServer")
val outgoingServerXml = outgoingServer.outerHtml()
outgoingServer.attr("type", "unsupported")
outgoingServer.html(outgoingServerXml)
}
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("No supported 'outgoingServer' element found")
}
@Test
fun `non XML data should throw`() {
val inputStream = "invalid".byteInputStream()
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Error parsing Autoconfig XML")
}
@Test
fun `wrong root element should throw`() {
@Language("XML")
val inputStream =
"""
<?xml version="1.0" encoding="UTF-8"?>
<serverConfig></serverConfig>
""".trimIndent().byteInputStream()
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Missing 'clientConfig' element")
}
@Test
fun `syntactically incorrect XML should throw`() {
@Language("XML")
val inputStream =
"""
<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="domain.example">
<incomingServer type="imap">
<hostname>imap.domain.example</hostname>
<port>993</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.domain.example</hostname>
<port>465</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<!-- Missing </emailProvider> -->
</clientConfig>
""".trimIndent().byteInputStream()
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("Error parsing Autoconfig XML")
}
@Test
fun `incomplete XML should throw`() {
@Language("XML")
val inputStream =
"""
<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="domain.example">
""".trimIndent().byteInputStream()
val result = parser.parseSettings(inputStream, irrelevantEmailAddress)
assertThat(result).isInstanceOf<ParserError>()
.prop(ParserError::error).hasMessage("End of document reached while reading element 'emailProvider'")
}
private fun String.withModifications(block: Document.() -> Unit): InputStream {
return Jsoup.parse(this, "", Parser.xmlParser())
.apply(block)
.toString()
.also {
if (PRINT_MODIFIED_XML) {
println(it)
}
}
.byteInputStream()
}
private fun Document.element(query: String): Element {
return select(query).first() ?: error("Couldn't find element using '$query'")
}
private fun Element.insertBefore(xml: String) {
val index = siblingIndex()
parent()!!.apply {
append(xml)
val newElement = lastElementChild()!!
newElement.remove()
insertChildren(index, newElement)
}
}
}

View file

@ -0,0 +1,95 @@
package app.k9mail.autodiscovery.autoconfig
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import kotlin.test.Test
import net.thunderbird.core.common.net.Domain
import net.thunderbird.core.common.net.toDomain
class RealSubDomainExtractorTest {
private val testBaseDomainExtractor = TestBaseDomainExtractor(baseDomain = "domain.example")
private val baseSubDomainExtractor = RealSubDomainExtractor(testBaseDomainExtractor)
@Test
fun `input has one more label than the base domain`() {
val domain = "subdomain.domain.example".toDomain()
val result = baseSubDomainExtractor.extractSubDomain(domain)
assertThat(result).isEqualTo("domain.example".toDomain())
}
@Test
fun `input has two more labels than the base domain`() {
val domain = "more.subdomain.domain.example".toDomain()
val result = baseSubDomainExtractor.extractSubDomain(domain)
assertThat(result).isEqualTo("subdomain.domain.example".toDomain())
}
@Test
fun `input has three more labels than the base domain`() {
val domain = "three.two.one.domain.example".toDomain()
val result = baseSubDomainExtractor.extractSubDomain(domain)
assertThat(result).isEqualTo("two.one.domain.example".toDomain())
}
@Test
fun `no sub domain available`() {
val domain = "domain.example".toDomain()
val result = baseSubDomainExtractor.extractSubDomain(domain)
assertThat(result).isNull()
}
@Test
fun `input has one more label than the base domain with public suffix`() {
val domain = "subdomain.example.co.uk".toDomain()
testBaseDomainExtractor.baseDomain = "example.co.uk"
val result = baseSubDomainExtractor.extractSubDomain(domain)
assertThat(result).isEqualTo("example.co.uk".toDomain())
}
@Test
fun `input has two more labels than the base domain with public suffix`() {
val domain = "more.subdomain.example.co.uk".toDomain()
testBaseDomainExtractor.baseDomain = "example.co.uk"
val result = baseSubDomainExtractor.extractSubDomain(domain)
assertThat(result).isEqualTo("subdomain.example.co.uk".toDomain())
}
@Test
fun `input has three more labels than the base domain with public suffix`() {
val domain = "three.two.one.example.co.uk".toDomain()
testBaseDomainExtractor.baseDomain = "example.co.uk"
val result = baseSubDomainExtractor.extractSubDomain(domain)
assertThat(result).isEqualTo("two.one.example.co.uk".toDomain())
}
@Test
fun `no sub domain available with public suffix`() {
val domain = "example.co.uk".toDomain()
testBaseDomainExtractor.baseDomain = "example.co.uk"
val result = baseSubDomainExtractor.extractSubDomain(domain)
assertThat(result).isNull()
}
}
private class TestBaseDomainExtractor(var baseDomain: String) : BaseDomainExtractor {
override fun extractBaseDomain(domain: Domain): Domain {
return Domain(baseDomain)
}
}

View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="googlemail.com">
<domain>gmail.com</domain>
<domain>googlemail.com</domain>
<!-- MX, for Google Apps -->
<domain>google.com</domain>
<!-- HACK. Only add ISPs with 100000+ users here -->
<domain>jazztel.es</domain>
<displayName>Google Mail</displayName>
<displayShortName>GMail</displayShortName>
<incomingServer type="imap">
<hostname>imap.gmail.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>OAuth2</authentication>
<authentication>password-cleartext</authentication>
</incomingServer>
<incomingServer type="pop3">
<hostname>pop.gmail.com</hostname>
<port>995</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>OAuth2</authentication>
<authentication>password-cleartext</authentication>
<pop3>
<leaveMessagesOnServer>true</leaveMessagesOnServer>
</pop3>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.gmail.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>OAuth2</authentication>
<authentication>password-cleartext</authentication>
</outgoingServer>
<documentation url="http://mail.google.com/support/bin/answer.py?answer=13273">
<descr>How to enable IMAP/POP3 in GMail</descr>
</documentation>
<documentation url="http://mail.google.com/support/bin/topic.py?topic=12806">
<descr>How to configure email clients for IMAP</descr>
</documentation>
<documentation url="http://mail.google.com/support/bin/topic.py?topic=12805">
<descr>How to configure email clients for POP3</descr>
</documentation>
<documentation url="http://mail.google.com/support/bin/answer.py?answer=86399">
<descr>How to configure TB 2.0 for POP3</descr>
</documentation>
</emailProvider>
<oAuth2>
<issuer>accounts.google.com</issuer>
<!-- https://developers.google.com/identity/protocols/oauth2/scopes -->
<scope>https://mail.google.com/ https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/carddav</scope>
<authURL>https://accounts.google.com/o/oauth2/auth</authURL>
<tokenURL>https://www.googleapis.com/oauth2/v3/token</tokenURL>
</oAuth2>
<enable visiturl="https://mail.google.com/mail/?ui=2&amp;shva=1#settings/fwdandpop">
<instruction>You need to enable IMAP access</instruction>
</enable>
<webMail>
<loginPage url="https://accounts.google.com/ServiceLogin?service=mail&amp;continue=http://mail.google.com/mail/" />
<loginPageInfo
url="https://accounts.google.com/ServiceLogin?service=mail&amp;continue=http://mail.google.com/mail/">
<username>%EMAILADDRESS%</username>
<usernameField id="Email" />
<passwordField id="Passwd" />
<loginButton id="signIn" />
</loginPageInfo>
</webMail>
</clientConfig>

View file

@ -0,0 +1,8 @@
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.android.lint)
}
dependencies {
api(projects.feature.autodiscovery.api)
}

View file

@ -0,0 +1,46 @@
package app.k9mail.autodiscovery.demo
import app.k9mail.autodiscovery.api.AutoDiscovery
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
import app.k9mail.autodiscovery.api.IncomingServerSettings
import app.k9mail.autodiscovery.api.OutgoingServerSettings
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import net.thunderbird.core.common.mail.EmailAddress
import net.thunderbird.core.common.mail.toDomain
class DemoAutoDiscovery : AutoDiscovery {
override fun initDiscovery(email: EmailAddress): List<AutoDiscoveryRunnable> {
val domain = email.domain.toDomain()
return listOf(
AutoDiscoveryRunnable {
if (domain.value == "example.com") {
AutoDiscoveryResult.Settings(
incomingServerSettings = DemoServerSettings,
outgoingServerSettings = DemoServerSettings,
isTrusted = true,
source = "DemoAutoDiscovery",
)
} else {
AutoDiscoveryResult.NoUsableSettingsFound
}
},
)
}
}
object DemoServerSettings : IncomingServerSettings, OutgoingServerSettings {
val serverSettings = ServerSettings(
type = "demo",
host = "irrelevant",
port = 23,
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
authenticationType = AuthType.PLAIN,
username = "irrelevant",
password = "irrelevant",
clientCertificateAlias = null,
)
}

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()
}
}
}