Repo created

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,104 @@
package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.AutoDiscovery
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NoUsableSettingsFound
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
import java.io.IOException
import net.thunderbird.core.common.mail.EmailAddress
import net.thunderbird.core.common.mail.toDomain
import net.thunderbird.core.common.net.Domain
import net.thunderbird.core.logging.legacy.Log
import okhttp3.OkHttpClient
import org.minidns.dnsname.InvalidDnsNameException
class MxLookupAutoconfigDiscovery internal constructor(
private val mxResolver: SuspendableMxResolver,
private val baseDomainExtractor: BaseDomainExtractor,
private val subDomainExtractor: SubDomainExtractor,
private val urlProvider: AutoconfigUrlProvider,
private val autoconfigFetcher: AutoconfigFetcher,
) : AutoDiscovery {
override fun initDiscovery(email: EmailAddress): List<AutoDiscoveryRunnable> {
return listOf(
AutoDiscoveryRunnable {
mxLookupAutoconfig(email)
},
)
}
@Suppress("ReturnCount")
private suspend fun mxLookupAutoconfig(email: EmailAddress): AutoDiscoveryResult {
val domain = email.domain.toDomain()
val mxLookupResult = mxLookup(domain) ?: return NoUsableSettingsFound
val mxHostName = mxLookupResult.mxNames.first()
val mxBaseDomain = getMxBaseDomain(mxHostName)
if (mxBaseDomain == domain) {
// Exit early to match Thunderbird's behavior.
return NoUsableSettingsFound
}
// In addition to just the base domain, also check the MX hostname without the first label to differentiate
// between Outlook.com/Hotmail and Office365 business domains.
val mxSubDomain = getNextSubDomain(mxHostName)?.takeIf { it != mxBaseDomain }
var latestResult: AutoDiscoveryResult = NoUsableSettingsFound
for (domainToCheck in listOfNotNull(mxSubDomain, mxBaseDomain)) {
for (autoconfigUrl in urlProvider.getAutoconfigUrls(domainToCheck, email)) {
val discoveryResult = autoconfigFetcher.fetchAutoconfig(autoconfigUrl, email)
if (discoveryResult is Settings) {
return discoveryResult.copy(
isTrusted = mxLookupResult.isTrusted && discoveryResult.isTrusted,
)
}
latestResult = discoveryResult
}
}
return latestResult
}
private suspend fun mxLookup(domain: Domain): MxLookupResult? {
// Only return the most preferred entry to match Thunderbird's behavior.
return try {
mxResolver.lookup(domain).takeIf { it.mxNames.isNotEmpty() }
} catch (e: IOException) {
Log.d(e, "Failed to get MX record for domain: %s", domain.value)
null
} catch (e: InvalidDnsNameException) {
Log.d(e, "Invalid DNS name for domain: %s", domain.value)
null
}
}
private fun getMxBaseDomain(mxHostName: Domain): Domain {
return baseDomainExtractor.extractBaseDomain(mxHostName)
}
private fun getNextSubDomain(domain: Domain): Domain? {
return subDomainExtractor.extractSubDomain(domain)
}
}
fun createMxLookupAutoconfigDiscovery(
okHttpClient: OkHttpClient,
config: AutoconfigUrlConfig,
): MxLookupAutoconfigDiscovery {
val baseDomainExtractor = OkHttpBaseDomainExtractor()
val autoconfigFetcher = RealAutoconfigFetcher(
fetcher = OkHttpFetcher(okHttpClient),
parser = SuspendableAutoconfigParser(RealAutoconfigParser()),
)
return MxLookupAutoconfigDiscovery(
mxResolver = SuspendableMxResolver(MiniDnsMxResolver()),
baseDomainExtractor = baseDomainExtractor,
subDomainExtractor = RealSubDomainExtractor(baseDomainExtractor),
urlProvider = createPostMxLookupAutoconfigUrlProvider(config),
autoconfigFetcher = autoconfigFetcher,
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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