Skip to content

Commit

Permalink
Add support for server settings autodiscovery
Browse files Browse the repository at this point in the history
This change makes account setup query mail provider's HTTPS server and
fills in appropriate settings for store and transport settings.

If there is no such file or the file is malformed the account setup
proceeds as usual with the manual setup.

If the user doesn't want to automatically discover server settings they
can initiate manual setup by pressing "Manual Setup" button on the first
screen.

Currently implemented autodiscovery scheme is Thunderbird Autoconfig but
this can be extended to additional methods (e.g. DNS or autodiscover).

Resolves thunderbird#865.

See: https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat
  • Loading branch information
wiktor-k committed Jan 17, 2019
1 parent cae0e22 commit 6c0425d
Show file tree
Hide file tree
Showing 3 changed files with 281 additions and 6 deletions.
134 changes: 134 additions & 0 deletions app/ui/src/main/java/com/fsck/k9/activity/setup/AccountDiscovery.kt
@@ -0,0 +1,134 @@
package com.fsck.k9.activity.setup

import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.launch
import org.jetbrains.anko.coroutines.experimental.bg
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
import timber.log.Timber
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder

class AccountDiscovery {

class DiscoveredSettings(val incoming: ServerSettings, val outgoing: ServerSettings)

fun discover(email: String, callback: (settings: DiscoveredSettings?) -> Unit) {
launch(UI) {
var settings: DiscoveredSettings? = null
try {
settings = bg {
val url = URL(getAutodiscoveryAddress(email))

with(url.openConnection()) {
parseSettings(inputStream, email)
}
}.await()
} catch (e: Exception) {
Timber.e("Autodiscovery error", e)
}

callback(settings)
}
}

// address described at:
// https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration#Configuration_server_at_ISP
internal fun getAutodiscoveryAddress(email: String): String {
val parts = email.split("@")
val domain = parts[1]
return "https://${domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=" + URLEncoder.encode(email, "UTF-8")
}

// structure described at:
// https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat
internal fun parseSettings(stream: InputStream, email: String): DiscoveredSettings? {
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 DiscoveredSettings(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
}
}
Expand Up @@ -42,6 +42,9 @@
import com.fsck.k9.mail.ServerSettings;
import com.fsck.k9.view.ClientCertificateSpinner;
import com.fsck.k9.view.ClientCertificateSpinner.OnClientCertificateChangedListener;

import kotlin.Unit;
import kotlin.jvm.functions.Function1;
import timber.log.Timber;

/**
Expand Down Expand Up @@ -346,16 +349,27 @@ private void onNext() {
return;
}

String email = mEmailView.getText().toString();
final String email = mEmailView.getText().toString();
String[] emailParts = splitEmail(email);
String domain = emailParts[1];
mProvider = findProviderForDomain(domain);
if (mProvider == null) {
/*
* We don't have default settings for this account, start the manual
* setup process.
*/
onManualSetup();
new AccountDiscovery().discover(email, new Function1<AccountDiscovery.DiscoveredSettings, Unit>() {
@Override
public Unit invoke(AccountDiscovery.DiscoveredSettings settings) {
if (settings == null) {
/*
* We don't have default settings for this account, start the manual
* setup process.
*/
onManualSetup();
} else {
onSetupWithSettings(email, mPasswordView.getText().toString(),
settings.getIncoming(), settings.getOutgoing());
}
return null;
}
});
return;
}

Expand All @@ -366,6 +380,22 @@ private void onNext() {
}
}

private void onSetupWithSettings(String email, String password, ServerSettings storeServer, ServerSettings transportServer) {
if (mAccount == null) {
mAccount = Preferences.getPreferences(this).newAccount();
mAccount.setChipColor(AccountCreator.pickColor(this));
}
mAccount.setName(getOwnerName());
mAccount.setEmail(email);

String storeUri = backendManager.createStoreUri(storeServer.newPassword(password));
String transportUri = backendManager.createTransportUri(transportServer.newPassword(password));
mAccount.setStoreUri(storeUri);
mAccount.setTransportUri(transportUri);

AccountSetupCheckSettings.actionCheckSettings(this, mAccount, CheckDirection.INCOMING);
}

@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (resultCode == RESULT_OK) {
Expand Down
@@ -0,0 +1,111 @@
package com.fsck.k9.activity.setup

import com.fsck.k9.RobolectricTest
import junit.framework.Assert.assertEquals
import junit.framework.Assert.assertNotNull
import org.junit.Test

class AccountDiscoveryTest : RobolectricTest() {
private val accountDiscovery = AccountDiscovery()

@Test
fun settingsExtract() {
val settings = accountDiscovery.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 = accountDiscovery.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 generatedUrls() {
val email = "test@metacode.biz"

val actual = accountDiscovery.getAutodiscoveryAddress(email)

val expected = "https://metacode.biz/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=test%40metacode.biz"

assertEquals(expected, actual)
}
}

0 comments on commit 6c0425d

Please sign in to comment.