Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-24 18:55:42 +01:00
parent a629de6271
commit 3cef7c5092
2161 changed files with 246605 additions and 2 deletions

View file

@ -0,0 +1,15 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.android.lint)
}
dependencies {
api(projects.app.autodiscovery.api)
compileOnly(libs.xmlpull)
implementation(libs.okhttp)
testImplementation(libs.kxml2)
testImplementation(libs.okhttp.mockwebserver)
}

View file

@ -0,0 +1,28 @@
package com.fsck.k9.autodiscovery.thunderbird
import com.fsck.k9.logging.Timber
import java.io.IOException
import java.io.InputStream
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
class ThunderbirdAutoconfigFetcher(private val okHttpClient: OkHttpClient) {
fun fetchAutoconfigFile(url: HttpUrl): InputStream? {
return try {
val request = Request.Builder().url(url).build()
val response = okHttpClient.newCall(request).execute()
if (response.isSuccessful) {
response.body?.byteStream()
} else {
null
}
} catch (e: IOException) {
Timber.d(e, "Error fetching URL: %s", url)
null
}
}
}

View file

@ -0,0 +1,102 @@
package com.fsck.k9.autodiscovery.thunderbird
import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings
import com.fsck.k9.autodiscovery.api.DiscoveryResults
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
/**
* Parser for Thunderbird's
* [Autoconfig file format](https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat)
*/
class ThunderbirdAutoconfigParser {
fun parseSettings(stream: InputStream, email: String): DiscoveryResults? {
val factory = XmlPullParserFactory.newInstance()
val xpp = factory.newPullParser()
xpp.setInput(InputStreamReader(stream))
val incomingServers = mutableListOf<DiscoveredServerSettings>()
val outgoingServers = mutableListOf<DiscoveredServerSettings>()
var eventType = xpp.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG) {
when (xpp.name) {
"incomingServer" -> {
incomingServers += parseServer(xpp, "incomingServer", email)
}
"outgoingServer" -> {
outgoingServers += parseServer(xpp, "outgoingServer", email)
}
}
}
eventType = xpp.next()
}
return DiscoveryResults(incomingServers, outgoingServers)
}
private fun parseServer(xpp: XmlPullParser, nodeName: String, email: String): DiscoveredServerSettings {
val type = xpp.getAttributeValue(null, "type")
var host: String? = null
var username: String? = null
var port: Int? = null
var authType: AuthType? = null
var connectionSecurity: ConnectionSecurity? = null
var eventType = xpp.eventType
while (!(eventType == XmlPullParser.END_TAG && nodeName == xpp.name)) {
if (eventType == XmlPullParser.START_TAG) {
when (xpp.name) {
"hostname" -> {
host = getText(xpp)
}
"port" -> {
port = getText(xpp).toInt()
}
"username" -> {
username = getText(xpp).replace("%EMAILADDRESS%", email)
}
"authentication" -> {
if (authType == null) authType = parseAuthType(getText(xpp))
}
"socketType" -> {
connectionSecurity = parseSocketType(getText(xpp))
}
}
}
eventType = xpp.next()
}
return DiscoveredServerSettings(type, host!!, port!!, connectionSecurity!!, authType, username)
}
private fun parseAuthType(authentication: String): AuthType? {
return when (authentication) {
"password-cleartext" -> AuthType.PLAIN
"TLS-client-cert" -> AuthType.EXTERNAL
"secure" -> AuthType.CRAM_MD5
else -> null
}
}
private fun parseSocketType(socketType: String): ConnectionSecurity? {
return when (socketType) {
"plain" -> ConnectionSecurity.NONE
"SSL" -> ConnectionSecurity.SSL_TLS_REQUIRED
"STARTTLS" -> ConnectionSecurity.STARTTLS_REQUIRED
else -> null
}
}
@Throws(XmlPullParserException::class, IOException::class)
private fun getText(xpp: XmlPullParser): String {
val eventType = xpp.next()
return if (eventType != XmlPullParser.TEXT) "" else xpp.text
}
}

View file

@ -0,0 +1,47 @@
package com.fsck.k9.autodiscovery.thunderbird
import com.fsck.k9.helper.EmailHelper
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
class ThunderbirdAutoconfigUrlProvider {
fun getAutoconfigUrls(email: String): List<HttpUrl> {
val domain = EmailHelper.getDomainFromEmailAddress(email)
requireNotNull(domain) { "Couldn't extract domain from email address: $email" }
return listOf(
createProviderUrl(domain, email),
createDomainUrl(scheme = "https", domain),
createDomainUrl(scheme = "http", domain),
createIspDbUrl(domain)
)
}
private fun createProviderUrl(domain: String?, email: String): HttpUrl {
// https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email}
return HttpUrl.Builder()
.scheme("https")
.host("autoconfig.$domain")
.addEncodedPathSegments("mail/config-v1.1.xml")
.addQueryParameter("emailaddress", email)
.build()
}
private fun createDomainUrl(scheme: String, domain: String): HttpUrl {
// https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml
// http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml
return HttpUrl.Builder()
.scheme(scheme)
.host(domain)
.addEncodedPathSegments(".well-known/autoconfig/mail/config-v1.1.xml")
.build()
}
private fun createIspDbUrl(domain: String): HttpUrl {
// https://autoconfig.thunderbird.net/v1.1/{domain}
return "https://autoconfig.thunderbird.net/v1.1/".toHttpUrl()
.newBuilder()
.addPathSegment(domain)
.build()
}
}

View file

@ -0,0 +1,28 @@
package com.fsck.k9.autodiscovery.thunderbird
import com.fsck.k9.autodiscovery.api.ConnectionSettingsDiscovery
import com.fsck.k9.autodiscovery.api.DiscoveryResults
class ThunderbirdDiscovery(
private val urlProvider: ThunderbirdAutoconfigUrlProvider,
private val fetcher: ThunderbirdAutoconfigFetcher,
private val parser: ThunderbirdAutoconfigParser
) : ConnectionSettingsDiscovery {
override fun discover(email: String): DiscoveryResults? {
val autoconfigUrls = urlProvider.getAutoconfigUrls(email)
return autoconfigUrls
.asSequence()
.mapNotNull { autoconfigUrl ->
fetcher.fetchAutoconfigFile(autoconfigUrl)?.use { inputStream ->
parser.parseSettings(inputStream, email)
}
}
.firstOrNull { result ->
result.incoming.isNotEmpty() || result.outgoing.isNotEmpty()
}
}
override fun toString(): String = "Thunderbird autoconfig"
}

View file

@ -0,0 +1,44 @@
package com.fsck.k9.autodiscovery.thunderbird
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import kotlin.test.assertNotNull
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Test
class ThunderbirdAutoconfigFetcherTest {
private val fetcher = ThunderbirdAutoconfigFetcher(OkHttpClient.Builder().build())
@Test
fun shouldHandleNonexistentUrl() {
val nonExistentUrl =
"https://autoconfig.domain.invalid/mail/config-v1.1.xml?emailaddress=test%40domain.example".toHttpUrl()
val inputStream = fetcher.fetchAutoconfigFile(nonExistentUrl)
assertThat(inputStream).isNull()
}
@Test
fun shouldHandleEmptyResponse() {
val server = MockWebServer().apply {
this.enqueue(
MockResponse()
.setBody("")
.setResponseCode(204),
)
start()
}
val url = server.url("/empty/")
val inputStream = fetcher.fetchAutoconfigFile(url)
assertNotNull(inputStream) { inputStream ->
assertThat(inputStream.available()).isEqualTo(0)
}
}
}

View file

@ -0,0 +1,174 @@
package com.fsck.k9.autodiscovery.thunderbird
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings
import com.fsck.k9.autodiscovery.api.DiscoveryResults
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import org.junit.Test
class ThunderbirdAutoconfigTest {
private val parser = ThunderbirdAutoconfigParser()
@Test
fun settingsExtract() {
val input =
"""
<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="metacode.biz">
<domain>metacode.biz</domain>
<incomingServer type="imap">
<hostname>imap.googlemail.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>OAuth2</authentication>
<authentication>password-cleartext</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.googlemail.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>OAuth2</authentication>
<authentication>password-cleartext</authentication>
<addThisServer>true</addThisServer>
</outgoingServer>
</emailProvider>
</clientConfig>
""".trimIndent().byteInputStream()
val connectionSettings = parser.parseSettings(input, "test@metacode.biz")
assertThat(connectionSettings).isNotNull()
assertThat(connectionSettings!!.incoming).isNotNull()
assertThat(connectionSettings.outgoing).isNotNull()
with(connectionSettings.incoming.first()) {
assertThat(host).isEqualTo("imap.googlemail.com")
assertThat(port).isEqualTo(993)
assertThat(username).isEqualTo("test@metacode.biz")
}
with(connectionSettings.outgoing.first()) {
assertThat(host).isEqualTo("smtp.googlemail.com")
assertThat(port).isEqualTo(465)
assertThat(username).isEqualTo("test@metacode.biz")
}
}
@Test
fun multipleServers() {
val input =
"""
<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="metacode.biz">
<domain>metacode.biz</domain>
<incomingServer type="imap">
<hostname>imap.googlemail.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>OAuth2</authentication>
<authentication>password-cleartext</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>first</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>OAuth2</authentication>
<authentication>password-cleartext</authentication>
<addThisServer>true</addThisServer>
</outgoingServer>
<outgoingServer type="smtp">
<hostname>second</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>OAuth2</authentication>
<authentication>password-cleartext</authentication>
<addThisServer>true</addThisServer>
</outgoingServer>
</emailProvider>
</clientConfig>
""".trimIndent().byteInputStream()
val discoveryResults = parser.parseSettings(input, "test@metacode.biz")
assertThat(discoveryResults).isNotNull()
assertThat(discoveryResults!!.outgoing).isNotNull()
with(discoveryResults.outgoing[0]) {
assertThat(host).isEqualTo("first")
assertThat(port).isEqualTo(465)
assertThat(username).isEqualTo("test@metacode.biz")
}
with(discoveryResults.outgoing[1]) {
assertThat(host).isEqualTo("second")
assertThat(port).isEqualTo(465)
assertThat(username).isEqualTo("test@metacode.biz")
}
}
@Test
fun invalidResponse() {
val input =
"""
<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="metacode.biz">
<domain>metacode.biz</domain>
""".trimIndent().byteInputStream()
val connectionSettings = parser.parseSettings(input, "test@metacode.biz")
assertThat(connectionSettings).isEqualTo(DiscoveryResults(listOf(), listOf()))
}
@Test
fun incompleteConfiguration() {
val input =
"""
<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="metacode.biz">
<domain>metacode.biz</domain>
<incomingServer type="imap">
<hostname>imap.googlemail.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>OAuth2</authentication>
<authentication>password-cleartext</authentication>
</incomingServer>
</emailProvider>
</clientConfig>
""".trimIndent().byteInputStream()
val connectionSettings = parser.parseSettings(input, "test@metacode.biz")
assertThat(connectionSettings).isEqualTo(
DiscoveryResults(
listOf(
DiscoveredServerSettings(
protocol = "imap",
host = "imap.googlemail.com",
port = 993,
security = ConnectionSecurity.SSL_TLS_REQUIRED,
authType = AuthType.PLAIN,
username = "test@metacode.biz"
)
),
listOf()
)
)
}
}

View file

@ -0,0 +1,21 @@
package com.fsck.k9.autodiscovery.thunderbird
import assertk.assertThat
import assertk.assertions.containsExactly
import org.junit.Test
class ThunderbirdAutoconfigUrlProviderTest {
private val urlProvider = ThunderbirdAutoconfigUrlProvider()
@Test
fun `getAutoconfigUrls with ASCII email address`() {
val autoconfigUrls = urlProvider.getAutoconfigUrls("test@domain.example")
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",
"http://domain.example/.well-known/autoconfig/mail/config-v1.1.xml",
"https://autoconfig.thunderbird.net/v1.1/domain.example"
)
}
}