Add Thunderbird autodiscovery code (part 1)

This commit is contained in:
Wiktor Kwapisiewicz 2019-01-16 13:52:46 +01:00 committed by cketti
parent 57062fd558
commit b7652205ae
7 changed files with 308 additions and 0 deletions

View file

@ -11,6 +11,7 @@ dependencies {
implementation project(":mail:common")
implementation "com.jakewharton.timber:timber:${versions.timber}"
implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}"
testImplementation project(':app:testing')
testImplementation project(":backend:imap")

View file

@ -0,0 +1,26 @@
package com.fsck.k9.autodiscovery.thunderbird
import com.fsck.k9.helper.EmailHelper
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.InputStream
import java.net.URLEncoder
class ThunderbirdAutoconfigFetcher(private val client: OkHttpClient) {
fun fetchAutoconfigFile(email: String): InputStream? {
val url = getAutodiscoveryAddress(email)
val request = Request.Builder().url(url).build()
return client.newCall(request).execute().body()?.byteStream()
}
companion object {
// address described at:
// https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration#Configuration_server_at_ISP
internal fun getAutodiscoveryAddress(email: String): String {
val domain = EmailHelper.getDomainFromEmailAddress(email)
return "https://${domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=" + URLEncoder.encode(email, "UTF-8")
}
}
}

View file

@ -0,0 +1,102 @@
package com.fsck.k9.autodiscovery.thunderbird
import com.fsck.k9.autodiscovery.ConnectionSettings
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
class ThunderbirdAutoconfigParser {
// structure described at:
// https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat
fun parseSettings(stream: InputStream?, email: String): ConnectionSettings? {
if (stream == null) {
return null
}
var incoming: ServerSettings? = null
var outgoing: ServerSettings? = null
val factory = XmlPullParserFactory.newInstance()
val xpp = factory.newPullParser()
xpp.setInput(InputStreamReader(stream))
var eventType = xpp.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG) {
if ("incomingServer" == xpp.name) {
incoming = parseServer(xpp, "incomingServer", email)
} else if ("outgoingServer" == xpp.name) {
outgoing = parseServer(xpp, "outgoingServer", email)
}
if (incoming != null && outgoing != null) {
return ConnectionSettings(incoming, outgoing)
}
}
eventType = xpp.next()
}
return null
}
private fun parseServer(xpp: XmlPullParser, nodeName: String, email: String): ServerSettings {
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) {
if (xpp.name == "hostname") {
host = getText(xpp)
} else if (xpp.name == "port") {
port = getText(xpp).toInt()
} else if (xpp.name == "username") {
username = getText(xpp).replace("%EMAILADDRESS%", email)
} else if (xpp.name == "authentication" && authType == null) {
authType = parseAuthType(getText(xpp))
} else if (xpp.name == "socketType") {
connectionSecurity = parseSocketType(getText(xpp))
}
}
eventType = xpp.next()
}
return ServerSettings(type, host, port!!, connectionSecurity, authType, username, null, null)
}
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,18 @@
package com.fsck.k9.autodiscovery.thunderbird
import com.fsck.k9.autodiscovery.ConnectionSettings
import com.fsck.k9.autodiscovery.ConnectionSettingsDiscovery
class ThunderbirdDiscovery(
private val fetcher: ThunderbirdAutoconfigFetcher,
private val parser: ThunderbirdAutoconfigParser
): ConnectionSettingsDiscovery {
override fun discover(email: String): ConnectionSettings? {
return fetcher.fetchAutoconfigFile(email).use {
parser.parseSettings(it, email)
}
}
override fun toString(): String = "Thunderbird autoconfig"
}

View file

@ -0,0 +1,143 @@
package com.fsck.k9.autodiscovery.thunderbird
import com.fsck.k9.RobolectricTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class ThunderbirdAutoconfigTest : RobolectricTest() {
private val parser = ThunderbirdAutoconfigParser()
@Test
fun settingsExtract() {
val settings = parser.parseSettings("""<?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>""".byteInputStream(), "test@metacode.biz")
assertNotNull(settings)
assertNotNull(settings?.outgoing)
assertNotNull(settings?.incoming)
assertEquals("imap.googlemail.com", settings?.incoming?.host)
assertEquals(993, settings?.incoming?.port)
assertEquals("test@metacode.biz", settings?.incoming?.username)
assertEquals("smtp.googlemail.com", settings?.outgoing?.host)
assertEquals(465, settings?.outgoing?.port)
assertEquals("test@metacode.biz", settings?.outgoing?.username)
}
@Test
fun pickFirstServer() {
val settings = parser.parseSettings("""<?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>""".byteInputStream(), "test@metacode.biz")
assertNotNull(settings)
assertNotNull(settings?.outgoing)
assertNotNull(settings?.incoming)
assertEquals("first", settings?.outgoing?.host)
assertEquals(465, settings?.outgoing?.port)
assertEquals("test@metacode.biz", settings?.outgoing?.username)
}
@Test
fun invalidResponse() {
val settings = parser.parseSettings("""<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="metacode.biz">
<domain>metacode.biz</domain>""".byteInputStream(), "test@metacode.biz")
assertNull(settings)
}
@Test
fun notCompleteConfiguration() {
val settings = parser.parseSettings("""<?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>""".byteInputStream(), "test@metacode.biz")
assertNull(settings)
}
@Test
fun generatedUrls() {
val email = "test@metacode.biz"
val actual = ThunderbirdAutoconfigFetcher.getAutodiscoveryAddress(email)
val expected = "https://metacode.biz/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=test%40metacode.biz"
assertEquals(expected, actual)
}
}

View file

@ -26,3 +26,18 @@
-keepclassmembers class * extends androidx.appcompat.widget.SearchView {
public <init>(android.content.Context);
}
# okhttp rules
# see: https://github.com/square/okhttp/blob/master/okhttp/src/main/resources/META-INF/proguard/okhttp3.pro
# JSR 305 annotations are for embedding nullability information.
-dontwarn javax.annotation.**
# A resource is loaded with a relative path so the package of this class must be preserved.
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
-dontwarn org.codehaus.mojo.animal_sniffer.*
# OkHttp platform used only on JVM and when Conscrypt dependency is available.
-dontwarn okhttp3.internal.platform.ConscryptPlatform

View file

@ -20,6 +20,9 @@ buildscript {
'commonsIo': '2.4',
'mime4j': '0.8.1',
// Note: Only use 3.12.x versions until we raise minSdkVersion to 21
'okhttp': '3.12.1',
'androidxTestRunner': '1.1.1',
'junit': '4.12',
'robolectric': '3.7.1',