diff --git a/app/autodiscovery/build.gradle b/app/autodiscovery/build.gradle index a5c60de4a..b70b31f83 100644 --- a/app/autodiscovery/build.gradle +++ b/app/autodiscovery/build.gradle @@ -10,6 +10,7 @@ dependencies { implementation "com.jakewharton.timber:timber:${versions.timber}" implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" + implementation "org.minidns:minidns-hla:${versions.minidns}" testImplementation project(':app:testing') testImplementation project(":backend:imap") diff --git a/app/autodiscovery/src/main/java/com/fsck/k9/autodiscovery/KoinModule.kt b/app/autodiscovery/src/main/java/com/fsck/k9/autodiscovery/KoinModule.kt index afed9a847..abb97ddfe 100644 --- a/app/autodiscovery/src/main/java/com/fsck/k9/autodiscovery/KoinModule.kt +++ b/app/autodiscovery/src/main/java/com/fsck/k9/autodiscovery/KoinModule.kt @@ -2,9 +2,13 @@ package com.fsck.k9.autodiscovery import com.fsck.k9.autodiscovery.providersxml.ProvidersXmlDiscovery import com.fsck.k9.autodiscovery.providersxml.ProvidersXmlProvider +import com.fsck.k9.autodiscovery.srvrecords.MiniDnsSrvResolver +import com.fsck.k9.autodiscovery.srvrecords.SrvServiceDiscovery import org.koin.dsl.module val autodiscoveryModule = module { factory { ProvidersXmlProvider(get()) } factory { ProvidersXmlDiscovery(get(), get()) } + single { MiniDnsSrvResolver() } + factory { SrvServiceDiscovery(get()) } } diff --git a/app/autodiscovery/src/main/java/com/fsck/k9/autodiscovery/srvrecords/MiniDnsSrvResolver.kt b/app/autodiscovery/src/main/java/com/fsck/k9/autodiscovery/srvrecords/MiniDnsSrvResolver.kt new file mode 100644 index 000000000..a99c09cb1 --- /dev/null +++ b/app/autodiscovery/src/main/java/com/fsck/k9/autodiscovery/srvrecords/MiniDnsSrvResolver.kt @@ -0,0 +1,28 @@ +package com.fsck.k9.autodiscovery.srvrecords + +import com.fsck.k9.mail.ConnectionSecurity +import org.minidns.dnsname.DnsName +import org.minidns.hla.ResolverApi +import org.minidns.hla.SrvProto + +class MiniDnsSrvResolver() : SrvResolver { + override fun lookup(domain: String, type: SrvType): List { + val result = ResolverApi.INSTANCE.resolveSrv( + DnsName.from(type.label), + SrvProto.tcp.dnsName, + DnsName.from(domain) + ) + return result.answersOrEmptySet.map { + MailService( + type, + it.target.toString(), + it.port, + it.priority, + if (type.assumeTls) + ConnectionSecurity.SSL_TLS_REQUIRED + else + ConnectionSecurity.STARTTLS_REQUIRED + ) + } + } +} diff --git a/app/autodiscovery/src/main/java/com/fsck/k9/autodiscovery/srvrecords/SrvResolver.kt b/app/autodiscovery/src/main/java/com/fsck/k9/autodiscovery/srvrecords/SrvResolver.kt new file mode 100644 index 000000000..78a8e0d81 --- /dev/null +++ b/app/autodiscovery/src/main/java/com/fsck/k9/autodiscovery/srvrecords/SrvResolver.kt @@ -0,0 +1,5 @@ +package com.fsck.k9.autodiscovery.srvrecords + +interface SrvResolver { + fun lookup(domain: String, type: SrvType): List +} diff --git a/app/autodiscovery/src/main/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscovery.kt b/app/autodiscovery/src/main/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscovery.kt new file mode 100644 index 000000000..f2b8275e2 --- /dev/null +++ b/app/autodiscovery/src/main/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscovery.kt @@ -0,0 +1,60 @@ +package com.fsck.k9.autodiscovery.srvrecords + +import com.fsck.k9.autodiscovery.ConnectionSettings +import com.fsck.k9.autodiscovery.ConnectionSettingsDiscovery +import com.fsck.k9.helper.EmailHelper +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings + +class SrvServiceDiscovery( + private val srvResolver: MiniDnsSrvResolver +) : ConnectionSettingsDiscovery { + override fun discover(email: String): ConnectionSettings? { + val domain = EmailHelper.getDomainFromEmailAddress(email) ?: return null + val pickMailService = compareBy { it.priority }.thenByDescending { it.security } + + val outgoingService = listOf(SrvType.SUBMISSIONS, SrvType.SUBMISSION).flatMap { srvResolver.lookup(domain, it) } + .minWith(pickMailService) ?: return null + val incomingService = listOf(SrvType.IMAPS, SrvType.IMAP).flatMap { srvResolver.lookup(domain, it) } + .minWith(pickMailService) ?: return null + + return ConnectionSettings( + incoming = ServerSettings( + incomingService.srvType.protocol, + incomingService.host, + incomingService.port, + incomingService.security, + AuthType.PLAIN, + email, + null, + null + ), + outgoing = ServerSettings( + outgoingService.srvType.protocol, + outgoingService.host, + outgoingService.port, + outgoingService.security, + AuthType.PLAIN, + email, + null, + null + ) + ) + } +} + +enum class SrvType(val label: String, val protocol: String, val assumeTls: Boolean) { + SUBMISSIONS("_submissions", "smtp", true), + SUBMISSION("_submission", "smtp", false), + IMAPS("_imaps", "imap", true), + IMAP("_imap", "imap", false) +} + +data class MailService( + val srvType: SrvType, + val host: String, + val port: Int, + val priority: Int, + val security: ConnectionSecurity? +) diff --git a/app/autodiscovery/src/test/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscoveryTest.kt b/app/autodiscovery/src/test/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscoveryTest.kt new file mode 100644 index 000000000..403814a9f --- /dev/null +++ b/app/autodiscovery/src/test/java/com/fsck/k9/autodiscovery/srvrecords/SrvServiceDiscoveryTest.kt @@ -0,0 +1,112 @@ +package com.fsck.k9.autodiscovery.srvrecords + +import com.fsck.k9.RobolectricTest +import com.fsck.k9.mail.ConnectionSecurity +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SrvServiceDiscoveryTest : RobolectricTest() { + + @Test + fun discover_whenNoMailServices_shouldReturnNull() { + val srvResolver = newMockSrvResolver() + + val srvServiceDiscovery = SrvServiceDiscovery(srvResolver) + val result = srvServiceDiscovery.discover("test@example.com") + + assertNull(result) + } + + @Test + fun discover_whenNoSMTP_shouldReturnNull() { + val srvResolver = newMockSrvResolver( + imapServices = listOf(newMailService(port = 143, srvType = SrvType.IMAP)), + imapsServices = listOf(newMailService(port = 993, srvType = SrvType.IMAPS, + security = ConnectionSecurity.SSL_TLS_REQUIRED)) + ) + val srvServiceDiscovery = SrvServiceDiscovery(srvResolver) + val result = srvServiceDiscovery.discover("test@example.com") + + assertNull(result) + } + + @Test + fun discover_whenNoIMAP_shouldReturnNull() { + val srvResolver = newMockSrvResolver(submissionServices = listOf( + newMailService(port = 25, srvType = SrvType.SUBMISSION, security = ConnectionSecurity.STARTTLS_REQUIRED), + newMailService(port = 465, srvType = SrvType.SUBMISSIONS, security = ConnectionSecurity.SSL_TLS_REQUIRED) + )) + + val srvServiceDiscovery = SrvServiceDiscovery(srvResolver) + val result = srvServiceDiscovery.discover("test@example.com") + + assertNull(result) + } + + @Test + fun discover_withRequiredServices_shouldCorrectlyPrioritize() { + val srvResolver = newMockSrvResolver(submissionServices = listOf( + newMailService( + host = "smtp1.example.com", port = 25, srvType = SrvType.SUBMISSION, + security = ConnectionSecurity.STARTTLS_REQUIRED, priority = 0), + newMailService( + host = "smtp2.example.com", port = 25, srvType = SrvType.SUBMISSION, + security = ConnectionSecurity.STARTTLS_REQUIRED, priority = 1) + ), submissionsServices = listOf( + newMailService( + host = "smtp3.example.com", port = 465, srvType = SrvType.SUBMISSIONS, + security = ConnectionSecurity.SSL_TLS_REQUIRED, priority = 0), + newMailService( + host = "smtp4.example.com", port = 465, srvType = SrvType.SUBMISSIONS, + security = ConnectionSecurity.SSL_TLS_REQUIRED, priority = 1) + ), imapServices = listOf( + newMailService( + host = "imap1.example.com", port = 143, srvType = SrvType.IMAP, + security = ConnectionSecurity.STARTTLS_REQUIRED, priority = 0), + newMailService( + host = "imap2.example.com", port = 143, srvType = SrvType.IMAP, + security = ConnectionSecurity.STARTTLS_REQUIRED, priority = 1) + ), imapsServices = listOf( + newMailService( + host = "imaps1.example.com", port = 993, srvType = SrvType.IMAPS, + security = ConnectionSecurity.SSL_TLS_REQUIRED, priority = 0), + newMailService( + host = "imaps2.example.com", port = 993, srvType = SrvType.IMAPS, + security = ConnectionSecurity.SSL_TLS_REQUIRED, priority = 1) + )) + + val srvServiceDiscovery = SrvServiceDiscovery(srvResolver) + val result = srvServiceDiscovery.discover("test@example.com") + + assertEquals("smtp3.example.com", result?.outgoing?.host) + assertEquals("imaps1.example.com", result?.incoming?.host) + } + + private fun newMailService( + host: String = "example.com", + priority: Int = 0, + security: ConnectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED, + srvType: SrvType, + port: Int + ): MailService { + return MailService(srvType, host, port, priority, security) + } + + private fun newMockSrvResolver( + host: String = "example.com", + submissionServices: List = listOf(), + submissionsServices: List = listOf(), + imapServices: List = listOf(), + imapsServices: List = listOf() + ): MiniDnsSrvResolver { + return mock { + on { lookup(host, SrvType.SUBMISSION) } doReturn submissionServices + on { lookup(host, SrvType.SUBMISSIONS) } doReturn submissionsServices + on { lookup(host, SrvType.IMAP) } doReturn imapServices + on { lookup(host, SrvType.IMAPS) } doReturn imapsServices + } + } +} diff --git a/build.gradle b/build.gradle index 81e1b140f..07b95bfb5 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,7 @@ buildscript { 'commonsIo': '2.6', 'mime4j': '0.8.1', 'okhttp': '4.5.0', + 'minidns': '0.3.4', 'androidxTestRunner': '1.1.1', 'junit': '4.13',