Rewrite AutoconfigParser
to be more robust
This commit is contained in:
parent
7d7e585b03
commit
9686270a1c
5 changed files with 825 additions and 227 deletions
|
@ -10,5 +10,6 @@ dependencies {
|
|||
implementation(libs.okhttp)
|
||||
|
||||
testImplementation(libs.kxml2)
|
||||
testImplementation(libs.jsoup)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="googlemail.com">
|
||||
<domain>gmail.com</domain>
|
||||
<domain>googlemail.com</domain>
|
||||
<!-- MX, for Google Apps -->
|
||||
<domain>google.com</domain>
|
||||
<!-- HACK. Only add ISPs with 100000+ users here -->
|
||||
<domain>jazztel.es</domain>
|
||||
|
||||
<displayName>Google Mail</displayName>
|
||||
<displayShortName>GMail</displayShortName>
|
||||
|
||||
<incomingServer type="imap">
|
||||
<hostname>imap.gmail.com</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>OAuth2</authentication>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</incomingServer>
|
||||
<incomingServer type="pop3">
|
||||
<hostname>pop.gmail.com</hostname>
|
||||
<port>995</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>OAuth2</authentication>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<pop3>
|
||||
<leaveMessagesOnServer>true</leaveMessagesOnServer>
|
||||
</pop3>
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>smtp.gmail.com</hostname>
|
||||
<port>465</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>OAuth2</authentication>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</outgoingServer>
|
||||
|
||||
<documentation url="http://mail.google.com/support/bin/answer.py?answer=13273">
|
||||
<descr>How to enable IMAP/POP3 in GMail</descr>
|
||||
</documentation>
|
||||
<documentation url="http://mail.google.com/support/bin/topic.py?topic=12806">
|
||||
<descr>How to configure email clients for IMAP</descr>
|
||||
</documentation>
|
||||
<documentation url="http://mail.google.com/support/bin/topic.py?topic=12805">
|
||||
<descr>How to configure email clients for POP3</descr>
|
||||
</documentation>
|
||||
<documentation url="http://mail.google.com/support/bin/answer.py?answer=86399">
|
||||
<descr>How to configure TB 2.0 for POP3</descr>
|
||||
</documentation>
|
||||
</emailProvider>
|
||||
|
||||
<oAuth2>
|
||||
<issuer>accounts.google.com</issuer>
|
||||
<!-- https://developers.google.com/identity/protocols/oauth2/scopes -->
|
||||
<scope>https://mail.google.com/ https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/carddav</scope>
|
||||
<authURL>https://accounts.google.com/o/oauth2/auth</authURL>
|
||||
<tokenURL>https://www.googleapis.com/oauth2/v3/token</tokenURL>
|
||||
</oAuth2>
|
||||
|
||||
<enable visiturl="https://mail.google.com/mail/?ui=2&shva=1#settings/fwdandpop">
|
||||
<instruction>You need to enable IMAP access</instruction>
|
||||
</enable>
|
||||
|
||||
<webMail>
|
||||
<loginPage url="https://accounts.google.com/ServiceLogin?service=mail&continue=http://mail.google.com/mail/" />
|
||||
<loginPageInfo
|
||||
url="https://accounts.google.com/ServiceLogin?service=mail&continue=http://mail.google.com/mail/">
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<usernameField id="Email" />
|
||||
<passwordField id="Passwd" />
|
||||
<loginButton id="signIn" />
|
||||
</loginPageInfo>
|
||||
</webMail>
|
||||
|
||||
</clientConfig>
|
Loading…
Reference in a new issue