Rewrite AutoconfigParser to be more robust

This commit is contained in:
cketti 2023-05-03 21:30:35 +02:00
parent 7d7e585b03
commit 9686270a1c
5 changed files with 825 additions and 227 deletions

View file

@ -10,5 +10,6 @@ dependencies {
implementation(libs.okhttp)
testImplementation(libs.kxml2)
testImplementation(libs.jsoup)
testImplementation(libs.okhttp.mockwebserver)
}

View file

@ -2,9 +2,11 @@ package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.DiscoveredServerSettings
import app.k9mail.autodiscovery.api.DiscoveryResults
import com.fsck.k9.helper.EmailHelper
import com.fsck.k9.helper.HostNameUtils
import com.fsck.k9.logging.Timber
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
@ -12,91 +14,267 @@ import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
/**
* Parser for Thunderbird's
* [Autoconfig file format](https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat)
* Parser for Thunderbird's Autoconfig file format.
*
* See [https://github.com/thundernest/autoconfig](https://github.com/thundernest/autoconfig)
*/
class AutoconfigParser {
fun parseSettings(stream: InputStream, email: String): DiscoveryResults? {
val factory = XmlPullParserFactory.newInstance()
val xpp = factory.newPullParser()
fun parseSettings(stream: InputStream, email: String): DiscoveryResults {
return try {
ClientConfigParser(stream, email).parse()
} catch (e: XmlPullParserException) {
throw AutoconfigParserException("Error parsing Autoconfig XML", e)
}
}
}
xpp.setInput(InputStreamReader(stream))
@Suppress("TooManyFunctions")
private class ClientConfigParser(
private val inputStream: InputStream,
private val email: String,
) {
private val localPart = requireNotNull(EmailHelper.getLocalPartFromEmailAddress(email)) { "Invalid email address" }
private val domain = requireNotNull(EmailHelper.getDomainFromEmailAddress(email)) { "Invalid email address" }
private val pullParser: XmlPullParser = XmlPullParserFactory.newInstance().newPullParser().apply {
setInput(InputStreamReader(inputStream))
}
private val incomingServers = mutableListOf<DiscoveredServerSettings>()
private val outgoingServers = mutableListOf<DiscoveredServerSettings>()
fun parse(): DiscoveryResults {
var clientConfigFound = false
do {
val eventType = pullParser.next()
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)
when (pullParser.name) {
"clientConfig" -> {
clientConfigFound = true
parseClientConfig()
}
else -> skipElement()
}
}
eventType = xpp.next()
} while (eventType != XmlPullParser.END_DOCUMENT)
if (!clientConfigFound) {
parserError("Missing 'clientConfig' element")
}
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
private fun parseClientConfig() {
var emailProviderFound = false
var eventType = xpp.eventType
while (!(eventType == XmlPullParser.END_TAG && nodeName == xpp.name)) {
readElement { eventType ->
if (eventType == XmlPullParser.START_TAG) {
when (xpp.name) {
"hostname" -> {
host = getText(xpp)
when (pullParser.name) {
"emailProvider" -> {
emailProviderFound = true
parseEmailProvider()
}
"port" -> {
port = getText(xpp).toInt()
else -> skipElement()
}
}
}
if (!emailProviderFound) {
parserError("Missing 'emailProvider' element")
}
}
private fun parseEmailProvider() {
var domainFound = false
// 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()
if (domain.isValidHostname()) {
domainFound = true
}
}
"username" -> {
username = getText(xpp).replace("%EMAILADDRESS%", email)
"incomingServer" -> {
parseServer()?.let { serverSettings ->
incomingServers.add(serverSettings)
}
}
"authentication" -> {
if (authType == null) authType = parseAuthType(getText(xpp))
"outgoingServer" -> {
parseServer()?.let { serverSe ->
outgoingServers.add(serverSe)
}
}
"socketType" -> {
connectionSecurity = parseSocketType(getText(xpp))
else -> {
skipElement()
}
}
}
eventType = xpp.next()
}
return DiscoveredServerSettings(type, host!!, port!!, connectionSecurity!!, authType, username)
// Thunderbird desktop requires at least one valid 'domain' element.
if (!domainFound) {
parserError("Valid 'domain' element required")
}
if (incomingServers.isEmpty()) {
parserError("Missing 'incomingServer' element")
}
if (outgoingServers.isEmpty()) {
parserError("Missing 'outgoingServer' element")
}
}
private fun parseAuthType(authentication: String): AuthType? {
return when (authentication) {
private fun parseServer(): DiscoveredServerSettings? {
val type = pullParser.getAttributeValue(null, "type")
if (type != "imap" && type != "smtp") {
return null
}
var hostName: String? = null
var port: Int? = null
var userName: String? = null
var authType: AuthType? = null
var connectionSecurity: ConnectionSecurity? = null
readElement { eventType ->
if (eventType == XmlPullParser.START_TAG) {
when (pullParser.name) {
"hostname" -> hostName = readHostname()
"port" -> port = readPort()
"username" -> userName = readUsername()
"authentication" -> authType = readAuthentication(authType)
"socketType" -> connectionSecurity = readSocketType()
}
}
}
if (hostName == null) {
parserError("Missing 'hostname' element")
} else if (port == null) {
parserError("Missing 'port' element")
} else if (userName == null) {
parserError("Missing 'username' element")
} else if (authType == null) {
parserError("No usable 'authentication' element found")
} else if (connectionSecurity == null) {
parserError("Missing 'socketType' element")
}
return DiscoveredServerSettings(type, hostName!!, port!!, connectionSecurity!!, authType, userName)
}
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(authType: AuthType?): AuthType? {
return authType ?: readText().toAuthType()
}
private fun readSocketType() = readText().toConnectionSecurity()
private fun String.toAuthType(): AuthType? {
return when (this) {
"OAuth2" -> AuthType.XOAUTH2
"password-cleartext" -> AuthType.PLAIN
"TLS-client-cert" -> AuthType.EXTERNAL
"secure" -> AuthType.CRAM_MD5
else -> null
"password-encrypted" -> AuthType.CRAM_MD5
else -> {
Timber.d("Ignoring unknown 'authentication' value '$this'")
null
}
}
}
private fun parseSocketType(socketType: String): ConnectionSecurity? {
return when (socketType) {
"plain" -> ConnectionSecurity.NONE
private fun String.toConnectionSecurity(): ConnectionSecurity {
return when (this) {
"SSL" -> ConnectionSecurity.SSL_TLS_REQUIRED
"STARTTLS" -> ConnectionSecurity.STARTTLS_REQUIRED
else -> null
else -> parserError("Unknown 'socketType' value: '$this'")
}
}
@Throws(XmlPullParserException::class, IOException::class)
private fun getText(xpp: XmlPullParser): String {
val eventType = xpp.next()
return if (eventType != XmlPullParser.TEXT) "" else xpp.text
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() {
Timber.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)
}
}
class AutoconfigParserException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)

View file

@ -0,0 +1,514 @@
package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.DiscoveredServerSettings
import app.k9mail.autodiscovery.api.DiscoveryResults
import assertk.all
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.hasMessage
import assertk.assertions.isFailure
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.prop
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import java.io.InputStream
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.Test
private const val PRINT_MODIFIED_XML = false
class AutoconfigParserTest {
private val parser = AutoconfigParser()
@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()
@Test
fun `minimal data`() {
val inputStream = minimalConfig.byteInputStream()
val result = parser.parseSettings(inputStream, email = "user@domain.example")
assertThat(result).isNotNull().all {
prop(DiscoveryResults::incoming).containsExactly(
DiscoveredServerSettings(
protocol = "imap",
host = "imap.domain.example",
port = 993,
security = ConnectionSecurity.SSL_TLS_REQUIRED,
authType = AuthType.PLAIN,
username = "user@domain.example",
),
)
prop(DiscoveryResults::outgoing).containsExactly(
DiscoveredServerSettings(
protocol = "smtp",
host = "smtp.domain.example",
port = 587,
security = ConnectionSecurity.STARTTLS_REQUIRED,
authType = AuthType.PLAIN,
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")
assertThat(result).isNotNull().all {
prop(DiscoveryResults::incoming).containsExactly(
DiscoveredServerSettings(
protocol = "imap",
host = "imap.gmail.com",
port = 993,
security = ConnectionSecurity.SSL_TLS_REQUIRED,
authType = AuthType.XOAUTH2,
username = "test@gmail.com",
),
)
prop(DiscoveryResults::outgoing).containsExactly(
DiscoveredServerSettings(
protocol = "smtp",
host = "smtp.gmail.com",
port = 465,
security = ConnectionSecurity.SSL_TLS_REQUIRED,
authType = AuthType.XOAUTH2,
username = "test@gmail.com",
),
)
}
}
@Test
fun `replace variables`() {
val inputStream = minimalConfig.withModifications {
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")
assertThat(result).isNotNull().all {
prop(DiscoveryResults::incoming).containsExactly(
DiscoveredServerSettings(
protocol = "imap",
host = "user.domain.example",
port = 993,
security = ConnectionSecurity.SSL_TLS_REQUIRED,
authType = AuthType.PLAIN,
username = "user@domain.example",
),
)
prop(DiscoveryResults::outgoing).containsExactly(
DiscoveredServerSettings(
protocol = "smtp",
host = "user.outgoing.domain.example",
port = 587,
security = ConnectionSecurity.STARTTLS_REQUIRED,
authType = AuthType.PLAIN,
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")
assertThat(result.incoming).containsExactly(
DiscoveredServerSettings(
protocol = "imap",
host = "imap.domain.example",
port = 993,
security = ConnectionSecurity.SSL_TLS_REQUIRED,
authType = AuthType.PLAIN,
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")
assertThat(result.incoming).containsExactly(
DiscoveredServerSettings(
protocol = "imap",
host = "imap.domain.example",
port = 993,
security = ConnectionSecurity.SSL_TLS_REQUIRED,
authType = AuthType.PLAIN,
username = "user@domain.example",
),
)
}
@Test
fun `config with missing 'emailProvider id' attribute should throw`() {
val inputStream = minimalConfig.withModifications {
element("emailProvider").removeAttr("id")
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Missing 'emailProvider.id' attribute")
}
@Test
fun `config with invalid 'emailProvider id' attribute should throw`() {
val inputStream = minimalConfig.withModifications {
element("emailProvider").attr("id", "-23")
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Invalid 'emailProvider.id' attribute")
}
@Test
fun `config with missing domain element should throw`() {
val inputStream = minimalConfig.withModifications {
element("emailProvider > domain").remove()
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Valid 'domain' element required")
}
@Test
fun `config with only invalid domain elements should throw`() {
val inputStream = minimalConfig.withModifications {
element("emailProvider > domain").text("-invalid")
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Valid 'domain' element required")
}
@Test
fun `config with missing 'incomingServer' element should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer").remove()
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Missing 'incomingServer' element")
}
@Test
fun `config with missing 'outgoingServer' element should throw`() {
val inputStream = minimalConfig.withModifications {
element("outgoingServer").remove()
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Missing 'outgoingServer' element")
}
@Test
fun `incomingServer with missing hostname should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > hostname").remove()
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Missing 'hostname' element")
}
@Test
fun `incomingServer with invalid hostname should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > hostname").text("in valid")
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Invalid 'hostname' value: 'in valid'")
}
@Test
fun `incomingServer with missing port should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > port").remove()
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Missing 'port' element")
}
@Test
fun `incomingServer with missing socketType should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > socketType").remove()
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Missing 'socketType' element")
}
@Test
fun `incomingServer with missing authentication should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > authentication").remove()
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("No usable 'authentication' element found")
}
@Test
fun `incomingServer with missing username should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > username").remove()
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Missing 'username' element")
}
@Test
fun `incomingServer with invalid port should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > port").text("invalid")
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.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")
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Invalid 'port' value: '100000'")
}
@Test
fun `incomingServer with unknown socketType should throw`() {
val inputStream = minimalConfig.withModifications {
element("incomingServer > socketType").text("TLS")
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.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/>")
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Expected text, but got START_TAG")
}
@Test
fun `ignore 'incomingServer' and 'outgoingServer' inside wrong element`() {
val inputStream = minimalConfig.withModifications {
element("emailProvider").tagName("madeUpTag")
}
assertThat {
parser.parseSettings(inputStream, email = "user@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.hasMessage("Missing 'emailProvider' element")
}
@Test
fun `non XML data should throw`() {
val inputStream = "invalid".byteInputStream()
assertThat {
parser.parseSettings(inputStream, email = "irrelevant@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.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()
assertThat {
parser.parseSettings(inputStream, email = "irrelevant@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.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()
assertThat {
parser.parseSettings(inputStream, email = "irrelevant@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.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()
assertThat {
parser.parseSettings(inputStream, email = "irrelevant@domain.example")
}.isFailure()
.isInstanceOf(AutoconfigParserException::class)
.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

@ -1,174 +0,0 @@
package app.k9mail.autodiscovery.autoconfig
import app.k9mail.autodiscovery.api.DiscoveredServerSettings
import app.k9mail.autodiscovery.api.DiscoveryResults
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import org.junit.Test
class ThunderbirdAutoconfigTest {
private val parser = AutoconfigParser()
@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,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>