Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
9
feature/autodiscovery/api/build.gradle.kts
Normal file
9
feature/autodiscovery/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.mail.common)
|
||||
api(projects.core.common)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package app.k9mail.autodiscovery.api
|
||||
|
||||
interface AutoDiscoveryRegistry {
|
||||
fun getAutoDiscoveries(): List<AutoDiscovery>
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package app.k9mail.autodiscovery.api
|
||||
|
||||
/**
|
||||
* The connection security methods supported when using the [AutoDiscovery] mechanism.
|
||||
*/
|
||||
enum class ConnectionSecurity {
|
||||
StartTLS,
|
||||
TLS,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
19
feature/autodiscovery/autoconfig/build.gradle.kts
Normal file
19
feature/autodiscovery/autoconfig/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package app.k9mail.autodiscovery.autoconfig
|
||||
|
||||
class AutoconfigParserException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package app.k9mail.autodiscovery.autoconfig
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
internal interface HttpFetcher {
|
||||
suspend fun fetch(url: HttpUrl): HttpFetchResult
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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&shva=1#settings/fwdandpop">
|
||||
<instruction>You need to enable IMAP access</instruction>
|
||||
</enable>
|
||||
|
||||
<webMail>
|
||||
<loginPage url="https://accounts.google.com/ServiceLogin?service=mail&continue=http://mail.google.com/mail/" />
|
||||
<loginPageInfo
|
||||
url="https://accounts.google.com/ServiceLogin?service=mail&continue=http://mail.google.com/mail/">
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<usernameField id="Email" />
|
||||
<passwordField id="Passwd" />
|
||||
<loginButton id="signIn" />
|
||||
</loginPageInfo>
|
||||
</webMail>
|
||||
|
||||
</clientConfig>
|
||||
8
feature/autodiscovery/demo/build.gradle.kts
Normal file
8
feature/autodiscovery/demo/build.gradle.kts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.feature.autodiscovery.api)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
13
feature/autodiscovery/service/build.gradle.kts
Normal file
13
feature/autodiscovery/service/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue