summaryrefslogtreecommitdiff
path: root/mail/protocols/smtp/src/main/java/com
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /mail/protocols/smtp/src/main/java/com
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'mail/protocols/smtp/src/main/java/com')
-rw-r--r--mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/EnhancedStatusCode.kt7
-rw-r--r--mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/NegativeSmtpReplyException.kt23
-rw-r--r--mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpHelloResponse.kt8
-rw-r--r--mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpLogger.kt9
-rw-r--r--mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponse.kt59
-rw-r--r--mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParser.kt418
-rw-r--r--mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserException.kt3
-rw-r--r--mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt690
-rw-r--r--mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/StatusCodeClass.kt7
9 files changed, 1224 insertions, 0 deletions
diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/EnhancedStatusCode.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/EnhancedStatusCode.kt
new file mode 100644
index 0000000..34718d3
--- /dev/null
+++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/EnhancedStatusCode.kt
@@ -0,0 +1,7 @@
+package com.fsck.k9.mail.transport.smtp
+
+data class EnhancedStatusCode(
+ val statusClass: StatusCodeClass,
+ val subject: Int,
+ val detail: Int
+)
diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/NegativeSmtpReplyException.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/NegativeSmtpReplyException.kt
new file mode 100644
index 0000000..8d430ab
--- /dev/null
+++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/NegativeSmtpReplyException.kt
@@ -0,0 +1,23 @@
+package com.fsck.k9.mail.transport.smtp
+
+import com.fsck.k9.mail.MessagingException
+
+/**
+ * Exception that is thrown when the server sends a negative reply (reply codes 4xx or 5xx).
+ */
+open class NegativeSmtpReplyException(
+ val replyCode: Int,
+ val replyText: String,
+ val enhancedStatusCode: EnhancedStatusCode? = null
+) : MessagingException(
+ buildErrorMessage(replyCode, replyText),
+ isPermanentSmtpError(replyCode)
+)
+
+private fun buildErrorMessage(replyCode: Int, replyText: String): String {
+ return replyText.ifEmpty { "Negative SMTP reply: $replyCode" }
+}
+
+private fun isPermanentSmtpError(replyCode: Int): Boolean {
+ return replyCode in 500..599
+}
diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpHelloResponse.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpHelloResponse.kt
new file mode 100644
index 0000000..559acdb
--- /dev/null
+++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpHelloResponse.kt
@@ -0,0 +1,8 @@
+package com.fsck.k9.mail.transport.smtp
+
+internal sealed interface SmtpHelloResponse {
+ val response: SmtpResponse
+
+ data class Error(override val response: SmtpResponse) : SmtpHelloResponse
+ data class Hello(override val response: SmtpResponse, val keywords: Map<String, List<String>>) : SmtpHelloResponse
+}
diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpLogger.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpLogger.kt
new file mode 100644
index 0000000..b442c49
--- /dev/null
+++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpLogger.kt
@@ -0,0 +1,9 @@
+package com.fsck.k9.mail.transport.smtp
+
+interface SmtpLogger {
+ val isRawProtocolLoggingEnabled: Boolean
+
+ fun log(message: String, vararg args: Any?) = log(throwable = null, message, *args)
+
+ fun log(throwable: Throwable?, message: String, vararg args: Any?)
+}
diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponse.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponse.kt
new file mode 100644
index 0000000..4fd8bfa
--- /dev/null
+++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponse.kt
@@ -0,0 +1,59 @@
+package com.fsck.k9.mail.transport.smtp
+
+internal data class SmtpResponse(
+ val replyCode: Int,
+ val enhancedStatusCode: EnhancedStatusCode?,
+ val texts: List<String>
+) {
+ val isNegativeResponse = replyCode >= 400
+
+ val joinedText: String
+ get() = texts.joinToString(separator = " ")
+
+ fun toLogString(omitText: Boolean, linePrefix: String): String {
+ return buildString {
+ if (omitText) {
+ append(linePrefix)
+ append(replyCode)
+ appendIfNotNull(enhancedStatusCode, prefix = ' ')
+ if (texts.isNotEmpty()) {
+ append(" [omitted]")
+ }
+ } else {
+ if (texts.size > 1) {
+ for (i in 0 until texts.lastIndex) {
+ append(linePrefix)
+ append(replyCode)
+ if (enhancedStatusCode == null) {
+ append('-')
+ } else {
+ appendIfNotNull(enhancedStatusCode, prefix = '-')
+ append(' ')
+ }
+ append(texts[i])
+ appendLine()
+ }
+ }
+
+ append(linePrefix)
+ append(replyCode)
+ appendIfNotNull(enhancedStatusCode, prefix = ' ')
+ if (texts.isNotEmpty()) {
+ append(' ')
+ append(texts.last())
+ }
+ }
+ }
+ }
+
+ private fun StringBuilder.appendIfNotNull(enhancedStatusCode: EnhancedStatusCode?, prefix: Char) {
+ if (enhancedStatusCode != null) {
+ append(prefix)
+ append(enhancedStatusCode.statusClass.codeClass)
+ append('.')
+ append(enhancedStatusCode.subject)
+ append('.')
+ append(enhancedStatusCode.detail)
+ }
+ }
+}
diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParser.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParser.kt
new file mode 100644
index 0000000..5428e12
--- /dev/null
+++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParser.kt
@@ -0,0 +1,418 @@
+package com.fsck.k9.mail.transport.smtp
+
+import com.fsck.k9.mail.filter.PeekableInputStream
+import okio.Buffer
+import okio.BufferedSource
+
+private const val CR = '\r'
+private const val LF = '\n'
+private const val SPACE = ' '
+private const val DASH = '-'
+private const val HTAB = '\t'
+private const val DOT = '.'
+private const val END_OF_STREAM = -1
+
+/**
+ * Parser for SMTP response lines.
+ *
+ * Supports enhanced status codes as defined in RFC 2034.
+ *
+ * Unfortunately at least one popular implementation doesn't always send an enhanced status code even though its EHLO
+ * response contains the ENHANCEDSTATUSCODES keyword. Begrudgingly, we allow this and other minor standard violations.
+ * However, we output a log message when such a case is encountered.
+ */
+internal class SmtpResponseParser(
+ private val logger: SmtpLogger,
+ private val input: PeekableInputStream
+) {
+ private val logBuffer = Buffer()
+
+ fun readGreeting(): SmtpResponse {
+ // We're not interested in the domain or address literal in the greeting. So we use the standard parser.
+ return readResponse(enhancedStatusCodes = false)
+ }
+
+ fun readHelloResponse(): SmtpHelloResponse {
+ logBuffer.clear()
+
+ val replyCode = readReplyCode()
+
+ if (replyCode != 250) {
+ val response = readResponseAfterReplyCode(replyCode, enhancedStatusCodes = false)
+ return SmtpHelloResponse.Error(response)
+ }
+
+ val texts = mutableListOf<String>()
+
+ // Read first line containing 'domain' and maybe 'ehlo-greet' (we don't check the syntax and allow any text)
+ when (val char = peekChar()) {
+ SPACE -> {
+ expect(SPACE)
+
+ val text = readUntilEndOfLine().readUtf8()
+
+ expect(CR)
+ expect(LF)
+
+ return SmtpHelloResponse.Hello(
+ response = SmtpResponse(replyCode, enhancedStatusCode = null, texts = listOf(text)),
+ keywords = emptyMap()
+ )
+ }
+ DASH -> {
+ expect(DASH)
+
+ val text = readUntilEndOfLine().readUtf8()
+ texts.add(text)
+
+ expect(CR)
+ expect(LF)
+ }
+ else -> unexpectedCharacterError(char)
+ }
+
+ val keywords = mutableMapOf<String, List<String>>()
+
+ // Read EHLO keywords and parameters
+ while (true) {
+ val currentReplyCode = readReplyCode()
+ if (currentReplyCode != replyCode) {
+ parserError("Multi-line response with reply codes not matching: $replyCode != $currentReplyCode")
+ }
+
+ when (val char = peekChar()) {
+ SPACE -> {
+ expect(SPACE)
+
+ val bufferedSource = readUntilEndOfLine()
+ val ehloLine = bufferedSource.readEhloLine()
+ texts.add(ehloLine)
+
+ parseEhloLine(ehloLine, keywords)
+
+ expect(CR)
+ expect(LF)
+
+ return SmtpHelloResponse.Hello(
+ response = SmtpResponse(replyCode, enhancedStatusCode = null, texts),
+ keywords = keywords
+ )
+ }
+ DASH -> {
+ expect(DASH)
+
+ val bufferedSource = readUntilEndOfLine()
+ val ehloLine = bufferedSource.readEhloLine()
+ texts.add(ehloLine)
+
+ parseEhloLine(ehloLine, keywords)
+
+ expect(CR)
+ expect(LF)
+ }
+ else -> unexpectedCharacterError(char)
+ }
+ }
+ }
+
+ private fun parseEhloLine(ehloLine: String, keywords: MutableMap<String, List<String>>) {
+ val parts = ehloLine.split(" ")
+
+ try {
+ val keyword = checkAndNormalizeEhloKeyword(parts[0])
+ val parameters = checkEhloParameters(parts)
+
+ if (keywords.containsKey(keyword)) {
+ parserError("Same EHLO keyword present in more than one response line", logging = false)
+ }
+
+ keywords[keyword] = parameters
+ } catch (e: SmtpResponseParserException) {
+ logger.log(e, "Ignoring EHLO keyword line: $ehloLine")
+ }
+ }
+
+ private fun checkAndNormalizeEhloKeyword(text: String): String {
+ val keyword = text.uppercase()
+ if (!keyword[0].isCapitalAlphaDigit() || keyword.any { !it.isCapitalAlphaDigit() && it != DASH }) {
+ parserError("EHLO keyword contains invalid character", logging = false)
+ }
+
+ return keyword
+ }
+
+ private fun checkEhloParameters(parts: List<String>): List<String> {
+ for (i in 1..parts.lastIndex) {
+ val parameter = parts[i]
+ if (parameter.isEmpty()) {
+ parserError("EHLO parameter must not be empty", logging = false)
+ } else if (parameter.any { it.code !in 33..126 }) {
+ parserError("EHLO parameter contains invalid character", logging = false)
+ }
+ }
+
+ return parts.drop(1)
+ }
+
+ fun readResponse(enhancedStatusCodes: Boolean): SmtpResponse {
+ logBuffer.clear()
+
+ val replyCode = readReplyCode()
+ return readResponseAfterReplyCode(replyCode, enhancedStatusCodes)
+ }
+
+ private fun readResponseAfterReplyCode(replyCode: Int, enhancedStatusCodes: Boolean): SmtpResponse {
+ val texts = mutableListOf<String>()
+ var enhancedStatusCode: EnhancedStatusCode? = null
+ var isFirstLine = true
+
+ fun BufferedSource.maybeReadAndCompareEnhancedStatusCode(replyCode: Int): EnhancedStatusCode? {
+ val currentStatusCode = maybeReadEnhancedStatusCode(replyCode)
+ if (!isFirstLine && enhancedStatusCode != currentStatusCode) {
+ parserError(
+ "Multi-line response with enhanced status codes not matching: " +
+ "$enhancedStatusCode != $currentStatusCode"
+ )
+ }
+ isFirstLine = false
+
+ return currentStatusCode
+ }
+
+ while (true) {
+ when (val char = peekChar()) {
+ CR -> {
+ expect(CR)
+ expect(LF)
+
+ return SmtpResponse(replyCode, enhancedStatusCode, texts)
+ }
+ SPACE -> {
+ expect(SPACE)
+
+ val bufferedSource = readUntilEndOfLine()
+
+ if (enhancedStatusCodes) {
+ enhancedStatusCode = bufferedSource.maybeReadAndCompareEnhancedStatusCode(replyCode)
+ }
+
+ val textString = bufferedSource.readTextString()
+ if (textString.isNotEmpty()) {
+ texts.add(textString)
+ }
+
+ expect(CR)
+ expect(LF)
+
+ return SmtpResponse(replyCode, enhancedStatusCode, texts)
+ }
+ DASH -> {
+ expect(DASH)
+
+ val bufferedSource = readUntilEndOfLine()
+
+ if (enhancedStatusCodes) {
+ enhancedStatusCode = bufferedSource.maybeReadAndCompareEnhancedStatusCode(replyCode)
+ }
+
+ val textString = bufferedSource.readTextString()
+ texts.add(textString)
+
+ expect(CR)
+ expect(LF)
+
+ val currentReplyCode = readReplyCode()
+ if (currentReplyCode != replyCode) {
+ parserError(
+ "Multi-line response with reply codes not matching: $replyCode != $currentReplyCode"
+ )
+ }
+ }
+ else -> unexpectedCharacterError(char)
+ }
+ }
+ }
+
+ private fun readReplyCode(): Int {
+ return readReplyCode1() * 100 + readReplyCode2() * 10 + readReplyCode3()
+ }
+
+ private fun readReplyCode1(): Int {
+ val replyCode1 = readDigit()
+ if (replyCode1 !in 2..5) parserError("Unsupported 1st reply code digit: $replyCode1")
+
+ return replyCode1
+ }
+
+ private fun readReplyCode2(): Int {
+ val replyCode2 = readDigit()
+ if (replyCode2 !in 0..5) {
+ logger.log("2nd digit of reply code outside of specified range (0..5): %d", replyCode2)
+ }
+
+ return replyCode2
+ }
+
+ private fun readReplyCode3(): Int {
+ return readDigit()
+ }
+
+ private fun readDigit(): Int {
+ val char = readChar()
+ if (char !in '0'..'9') unexpectedCharacterError(char)
+
+ return char - '0'
+ }
+
+ private fun expect(expectedChar: Char) {
+ val char = readChar()
+ if (char != expectedChar) unexpectedCharacterError(char)
+ }
+
+ private fun readByte(): Int {
+ return input.read()
+ .also {
+ throwIfEndOfStreamReached(it)
+ logBuffer.writeByte(it)
+ }
+ }
+
+ private fun readChar(): Char {
+ return readByte().toChar()
+ }
+
+ private fun peekChar(): Char {
+ return input.peek()
+ .also { throwIfEndOfStreamReached(it) }
+ .toChar()
+ }
+
+ private fun throwIfEndOfStreamReached(data: Int) {
+ if (data == END_OF_STREAM) parserError("Unexpected end of stream")
+ }
+
+ private fun readUntilEndOfLine(): BufferedSource {
+ val buffer = Buffer()
+
+ while (peekChar() != CR) {
+ val byte = readByte()
+ buffer.writeByte(byte)
+ }
+
+ return buffer
+ }
+
+ private fun BufferedSource.readEhloLine(): String {
+ val text = readUtf8()
+ if (text.isEmpty()) {
+ parserError("EHLO line must not be empty")
+ }
+
+ return text
+ }
+
+ private fun BufferedSource.readTextString(): String {
+ val text = readUtf8()
+ if (text.isEmpty()) {
+ logger.log("'textstring' expected, but CR found instead")
+ } else if (text.any { it != HTAB && it.code !in 32..126 }) {
+ logger.log("Text contains characters not allowed in 'textstring'")
+ }
+
+ return text
+ }
+
+ private fun BufferedSource.maybeReadEnhancedStatusCode(replyCode: Int): EnhancedStatusCode? {
+ val replyCode1 = replyCode / 100
+ if (replyCode1 != 2 && replyCode1 != 4 && replyCode1 != 5) return null
+
+ return try {
+ val peekBufferedSource = peek()
+ val statusCode = peekBufferedSource.readEnhancedStatusCode(replyCode1)
+
+ val statusCodeLength = buffer.size - peekBufferedSource.buffer.size
+ skip(statusCodeLength)
+
+ statusCode
+ } catch (e: SmtpResponseParserException) {
+ logger.log(e, "Error parsing enhanced status code")
+ null
+ }
+ }
+
+ private fun BufferedSource.readEnhancedStatusCode(replyCode1: Int): EnhancedStatusCode {
+ val statusClass = readStatusCodeClass(replyCode1)
+ expect(DOT)
+ val subject = readOneToThreeDigitNumber()
+ expect(DOT)
+ val detail = readOneToThreeDigitNumber()
+
+ expect(SPACE)
+
+ return EnhancedStatusCode(statusClass, subject, detail)
+ }
+
+ private fun BufferedSource.readStatusCodeClass(replyCode1: Int): StatusCodeClass {
+ val char = readChar()
+ val statusClass = when (char) {
+ '2' -> StatusCodeClass.SUCCESS
+ '4' -> StatusCodeClass.PERSISTENT_TRANSIENT_FAILURE
+ '5' -> StatusCodeClass.PERMANENT_FAILURE
+ else -> unexpectedCharacterError(char, logging = false)
+ }
+
+ if (char != replyCode1.digitToChar()) {
+ parserError("Reply code doesn't match status code class: $replyCode1 != $char", logging = false)
+ }
+
+ return statusClass
+ }
+
+ private fun BufferedSource.readOneToThreeDigitNumber(): Int {
+ var number = readDigit()
+ repeat(2) {
+ if (peek().readChar() in '0'..'9') {
+ number *= 10
+ number += readDigit()
+ }
+ }
+
+ return number
+ }
+
+ private fun BufferedSource.readDigit(): Int {
+ val char = readChar()
+ if (char !in '0'..'9') unexpectedCharacterError(char, logging = false)
+
+ return char - '0'
+ }
+
+ private fun BufferedSource.readChar(): Char {
+ if (exhausted()) parserError("Unexpected end of stream", logging = false)
+
+ return readByte().toInt().toChar()
+ }
+
+ private fun BufferedSource.expect(expectedChar: Char) {
+ val char = readChar()
+ if (char != expectedChar) unexpectedCharacterError(char, logging = false)
+ }
+
+ private fun unexpectedCharacterError(char: Char, logging: Boolean = true): Nothing {
+ if (char.code in 33..126) {
+ parserError("Unexpected character: $char (${char.code})", logging)
+ } else {
+ parserError("Unexpected character: (${char.code})", logging)
+ }
+ }
+
+ private fun parserError(message: String, logging: Boolean = true): Nothing {
+ if (logging && logger.isRawProtocolLoggingEnabled) {
+ logger.log("SMTP response data on parser error:\n%s", logBuffer.readUtf8().replace("\r\n", "\n"))
+ }
+
+ throw SmtpResponseParserException(message)
+ }
+
+ private fun Char.isCapitalAlphaDigit(): Boolean = this in '0'..'9' || this in 'A'..'Z'
+}
diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserException.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserException.kt
new file mode 100644
index 0000000..e5ff757
--- /dev/null
+++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserException.kt
@@ -0,0 +1,3 @@
+package com.fsck.k9.mail.transport.smtp
+
+class SmtpResponseParserException(message: String) : RuntimeException(message)
diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt
new file mode 100644
index 0000000..790fb56
--- /dev/null
+++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt
@@ -0,0 +1,690 @@
+package com.fsck.k9.mail.transport.smtp
+
+import com.fsck.k9.logging.Timber
+import com.fsck.k9.mail.Address
+import com.fsck.k9.mail.AuthType
+import com.fsck.k9.mail.Authentication
+import com.fsck.k9.mail.AuthenticationFailedException
+import com.fsck.k9.mail.CertificateValidationException
+import com.fsck.k9.mail.ConnectionSecurity
+import com.fsck.k9.mail.K9MailLib
+import com.fsck.k9.mail.Message
+import com.fsck.k9.mail.Message.RecipientType
+import com.fsck.k9.mail.MessagingException
+import com.fsck.k9.mail.NetworkTimeouts.SOCKET_CONNECT_TIMEOUT
+import com.fsck.k9.mail.NetworkTimeouts.SOCKET_READ_TIMEOUT
+import com.fsck.k9.mail.ServerSettings
+import com.fsck.k9.mail.filter.Base64
+import com.fsck.k9.mail.filter.EOLConvertingOutputStream
+import com.fsck.k9.mail.filter.LineWrapOutputStream
+import com.fsck.k9.mail.filter.PeekableInputStream
+import com.fsck.k9.mail.filter.SmtpDataStuffing
+import com.fsck.k9.mail.oauth.OAuth2TokenProvider
+import com.fsck.k9.mail.oauth.XOAuth2ChallengeParser
+import com.fsck.k9.mail.ssl.TrustedSocketFactory
+import com.fsck.k9.mail.transport.smtp.SmtpHelloResponse.Hello
+import com.fsck.k9.sasl.buildOAuthBearerInitialClientResponse
+import java.io.BufferedInputStream
+import java.io.BufferedOutputStream
+import java.io.IOException
+import java.io.OutputStream
+import java.net.Inet6Address
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.net.UnknownHostException
+import java.security.GeneralSecurityException
+import java.security.cert.CertificateException
+import java.util.Locale
+import javax.net.ssl.SSLException
+import org.apache.commons.io.IOUtils
+import org.jetbrains.annotations.VisibleForTesting
+
+private const val SOCKET_SEND_MESSAGE_READ_TIMEOUT = 5 * 60 * 1000 // 5 minutes
+
+private const val SMTP_CONTINUE_REQUEST = 334
+private const val SMTP_AUTHENTICATION_FAILURE_ERROR_CODE = 535
+
+class SmtpTransport(
+ serverSettings: ServerSettings,
+ private val trustedSocketFactory: TrustedSocketFactory,
+ private val oauthTokenProvider: OAuth2TokenProvider?
+) {
+ private val host = serverSettings.host
+ private val port = serverSettings.port
+ private val username = serverSettings.username
+ private val password = serverSettings.password
+ private val clientCertificateAlias = serverSettings.clientCertificateAlias
+ private val authType = serverSettings.authenticationType
+ private val connectionSecurity = serverSettings.connectionSecurity
+
+ private var socket: Socket? = null
+ private var inputStream: PeekableInputStream? = null
+ private var outputStream: OutputStream? = null
+ private var responseParser: SmtpResponseParser? = null
+ private var is8bitEncodingAllowed = false
+ private var isEnhancedStatusCodesProvided = false
+ private var largestAcceptableMessage = 0
+ private var retryOAuthWithNewToken = false
+ private var isPipeliningSupported = false
+
+ private val logger: SmtpLogger = object : SmtpLogger {
+ override val isRawProtocolLoggingEnabled: Boolean
+ get() = K9MailLib.isDebug()
+
+ override fun log(throwable: Throwable?, message: String, vararg args: Any?) {
+ Timber.v(throwable, message, *args)
+ }
+ }
+
+ init {
+ require(serverSettings.type == "smtp") { "Expected SMTP ServerSettings!" }
+ }
+
+ // TODO: Fix tests to not use open() directly
+ @VisibleForTesting
+ @Throws(MessagingException::class)
+ internal fun open() {
+ try {
+ var secureConnection = connectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED
+
+ val socket = connect()
+ this.socket = socket
+
+ socket.soTimeout = SOCKET_READ_TIMEOUT
+
+ inputStream = PeekableInputStream(BufferedInputStream(socket.getInputStream(), 1024))
+ responseParser = SmtpResponseParser(logger, inputStream!!)
+ outputStream = BufferedOutputStream(socket.getOutputStream(), 1024)
+
+ readGreeting()
+
+ val helloName = buildHostnameToReport()
+ var extensions = sendHello(helloName)
+
+ is8bitEncodingAllowed = extensions.containsKey("8BITMIME")
+ isEnhancedStatusCodesProvided = extensions.containsKey("ENHANCEDSTATUSCODES")
+ isPipeliningSupported = extensions.containsKey("PIPELINING")
+
+ if (connectionSecurity == ConnectionSecurity.STARTTLS_REQUIRED) {
+ if (extensions.containsKey("STARTTLS")) {
+ executeCommand("STARTTLS")
+
+ val tlsSocket = trustedSocketFactory.createSocket(
+ socket,
+ host,
+ port,
+ clientCertificateAlias
+ )
+ this.socket = tlsSocket
+ inputStream = PeekableInputStream(BufferedInputStream(tlsSocket.getInputStream(), 1024))
+ responseParser = SmtpResponseParser(logger, inputStream!!)
+ outputStream = BufferedOutputStream(tlsSocket.getOutputStream(), 1024)
+
+ // Now resend the EHLO. Required by RFC2487 Sec. 5.2, and more specifically, Exim.
+ extensions = sendHello(helloName)
+ secureConnection = true
+ } else {
+ // This exception triggers a "Certificate error" notification that takes the user to the incoming
+ // server settings for review. This might be needed if the account was configured with an obsolete
+ // "STARTTLS (if available)" setting.
+ throw CertificateValidationException("STARTTLS connection security not available")
+ }
+ }
+
+ var authLoginSupported = false
+ var authPlainSupported = false
+ var authCramMD5Supported = false
+ var authExternalSupported = false
+ var authXoauth2Supported = false
+ var authOAuthBearerSupported = false
+ val saslMechanisms = extensions["AUTH"]
+ if (saslMechanisms != null) {
+ authLoginSupported = saslMechanisms.contains("LOGIN")
+ authPlainSupported = saslMechanisms.contains("PLAIN")
+ authCramMD5Supported = saslMechanisms.contains("CRAM-MD5")
+ authExternalSupported = saslMechanisms.contains("EXTERNAL")
+ authXoauth2Supported = saslMechanisms.contains("XOAUTH2")
+ authOAuthBearerSupported = saslMechanisms.contains("OAUTHBEARER")
+ }
+ parseOptionalSizeValue(extensions["SIZE"])
+
+ if (
+ username.isNotEmpty() &&
+ (!password.isNullOrEmpty() || AuthType.EXTERNAL == authType || AuthType.XOAUTH2 == authType)
+ ) {
+ when (authType) {
+ AuthType.LOGIN, AuthType.PLAIN -> {
+ // try saslAuthPlain first, because it supports UTF-8 explicitly
+ if (authPlainSupported) {
+ saslAuthPlain()
+ } else if (authLoginSupported) {
+ saslAuthLogin()
+ } else {
+ throw MessagingException("Authentication methods SASL PLAIN and LOGIN are unavailable.")
+ }
+ }
+ AuthType.CRAM_MD5 -> {
+ if (authCramMD5Supported) {
+ saslAuthCramMD5()
+ } else {
+ throw MessagingException("Authentication method CRAM-MD5 is unavailable.")
+ }
+ }
+ AuthType.XOAUTH2 -> {
+ if (oauthTokenProvider == null) {
+ throw MessagingException("No OAuth2TokenProvider available.")
+ } else if (authOAuthBearerSupported) {
+ saslOAuth(OAuthMethod.OAUTHBEARER)
+ } else if (authXoauth2Supported) {
+ saslOAuth(OAuthMethod.XOAUTH2)
+ } else {
+ throw MessagingException("Server doesn't support SASL OAUTHBEARER or XOAUTH2.")
+ }
+ }
+ AuthType.EXTERNAL -> {
+ if (authExternalSupported) {
+ saslAuthExternal()
+ } else {
+ // Some SMTP servers are known to provide no error indication when a client certificate
+ // fails to validate, other than to not offer the AUTH EXTERNAL capability.
+ // So, we treat it is an error to not offer AUTH EXTERNAL when using client certificates.
+ // That way, the user can be notified of a problem during account setup.
+ throw CertificateValidationException(
+ CertificateValidationException.Reason.MissingCapability
+ )
+ }
+ }
+ AuthType.AUTOMATIC -> {
+ if (secureConnection) {
+ // try saslAuthPlain first, because it supports UTF-8 explicitly
+ if (authPlainSupported) {
+ saslAuthPlain()
+ } else if (authLoginSupported) {
+ saslAuthLogin()
+ } else if (authCramMD5Supported) {
+ saslAuthCramMD5()
+ } else {
+ throw MessagingException("No supported authentication methods available.")
+ }
+ } else {
+ if (authCramMD5Supported) {
+ saslAuthCramMD5()
+ } else {
+ // We refuse to insecurely transmit the password using the obsolete AUTOMATIC setting
+ // because of the potential for a MITM attack. Affected users must choose a different
+ // setting.
+ throw MessagingException(
+ "Update your outgoing server authentication setting. " +
+ "AUTOMATIC authentication is unavailable."
+ )
+ }
+ }
+ }
+ else -> {
+ throw MessagingException("Unhandled authentication method found in server settings (bug).")
+ }
+ }
+ }
+ } catch (e: MessagingException) {
+ close()
+ throw e
+ } catch (e: SSLException) {
+ close()
+ if (e.cause is CertificateException) {
+ throw CertificateValidationException(e.message, e)
+ } else {
+ throw e
+ }
+ } catch (e: GeneralSecurityException) {
+ close()
+ throw MessagingException("Unable to open connection to SMTP server due to security error.", e)
+ } catch (e: IOException) {
+ close()
+ throw MessagingException("Unable to open connection to SMTP server.", e)
+ }
+ }
+
+ private fun connect(): Socket {
+ val inetAddresses = InetAddress.getAllByName(host)
+
+ var connectException: Exception? = null
+ for (address in inetAddresses) {
+ connectException = try {
+ return connectToAddress(address)
+ } catch (e: IOException) {
+ Timber.w(e, "Could not connect to %s", address)
+ e
+ }
+ }
+
+ throw connectException ?: UnknownHostException()
+ }
+
+ private fun connectToAddress(address: InetAddress): Socket {
+ if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_SMTP) {
+ Timber.d("Connecting to %s as %s", host, address)
+ }
+
+ val socketAddress = InetSocketAddress(address, port)
+ val socket = if (connectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED) {
+ trustedSocketFactory.createSocket(null, host, port, clientCertificateAlias)
+ } else {
+ Socket()
+ }
+
+ socket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT)
+
+ return socket
+ }
+
+ private fun readGreeting() {
+ val smtpResponse = responseParser!!.readGreeting()
+ logResponse(smtpResponse)
+ }
+
+ private fun logResponse(smtpResponse: SmtpResponse, sensitive: Boolean = false) {
+ if (K9MailLib.isDebug()) {
+ val omitText = sensitive && !K9MailLib.isDebugSensitive()
+ Timber.v("%s", smtpResponse.toLogString(omitText, linePrefix = "SMTP <<< "))
+ }
+ }
+
+ private fun buildHostnameToReport(): String {
+ val localAddress = socket!!.localAddress
+
+ // We use local IP statically for privacy reasons, see https://github.com/thundernest/k-9/pull/3798
+ return if (localAddress is Inet6Address) {
+ "[IPv6:::1]"
+ } else {
+ "[127.0.0.1]"
+ }
+ }
+
+ private fun parseOptionalSizeValue(sizeParameters: List<String>?) {
+ if (sizeParameters != null && sizeParameters.isNotEmpty()) {
+ val sizeParameter = sizeParameters.first()
+ val size = sizeParameter.toIntOrNull()
+ if (size != null) {
+ largestAcceptableMessage = size
+ } else {
+ if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_SMTP) {
+ Timber.d("SIZE parameter is not a valid integer: %s", sizeParameter)
+ }
+ }
+ }
+ }
+
+ /**
+ * Send the client "identity" using the `EHLO` or `HELO` command.
+ *
+ * We first try the EHLO command. If the server sends a negative response, it probably doesn't support the
+ * `EHLO` command. So we try the older `HELO` command that all servers have to support. And if that fails, too,
+ * we pretend everything is fine and continue unimpressed.
+ *
+ * @param host The EHLO/HELO parameter as defined by the RFC.
+ *
+ * @return A (possibly empty) `Map<String, List<String>>` of extensions (upper case) and their parameters
+ * (possibly empty) as returned by the EHLO command.
+ */
+ private fun sendHello(host: String): Map<String, List<String>> {
+ writeLine("EHLO $host")
+
+ val helloResponse = responseParser!!.readHelloResponse()
+ logResponse(helloResponse.response)
+
+ return if (helloResponse is Hello) {
+ helloResponse.keywords
+ } else {
+ if (K9MailLib.isDebug()) {
+ Timber.v("Server doesn't support the EHLO command. Trying HELO...")
+ }
+
+ try {
+ executeCommand("HELO %s", host)
+ } catch (e: NegativeSmtpReplyException) {
+ Timber.w("Server doesn't support the HELO command. Continuing anyway.")
+ }
+
+ emptyMap()
+ }
+ }
+
+ @Throws(MessagingException::class)
+ fun sendMessage(message: Message) {
+ val addresses = buildSet<String> {
+ for (address in message.getRecipients(RecipientType.TO)) {
+ add(address.address)
+ }
+
+ for (address in message.getRecipients(RecipientType.CC)) {
+ add(address.address)
+ }
+
+ for (address in message.getRecipients(RecipientType.BCC)) {
+ add(address.address)
+ }
+ }
+
+ if (addresses.isEmpty()) {
+ return
+ }
+
+ message.removeHeader("Bcc")
+
+ ensureClosed()
+ open()
+
+ // If the message has attachments and our server has told us about a limit on the size of messages, count
+ // the message's size before sending it.
+ if (largestAcceptableMessage > 0 && message.hasAttachments()) {
+ if (message.calculateSize() > largestAcceptableMessage) {
+ throw MessagingException("Message too large for server", true)
+ }
+ }
+
+ var entireMessageSent = false
+ try {
+ val mailFrom = constructSmtpMailFromCommand(message.from, is8bitEncodingAllowed)
+ if (isPipeliningSupported) {
+ val pipelinedCommands = buildList {
+ add(mailFrom)
+
+ for (address in addresses) {
+ add(String.format("RCPT TO:<%s>", address))
+ }
+ }
+
+ executePipelinedCommands(pipelinedCommands)
+ readPipelinedResponse(pipelinedCommands)
+ } else {
+ executeCommand(mailFrom)
+
+ for (address in addresses) {
+ executeCommand("RCPT TO:<%s>", address)
+ }
+ }
+
+ executeCommand("DATA")
+
+ // Sending large messages might take a long time. We're using an extended timeout while waiting for the
+ // final response to the DATA command.
+ val socket = this.socket ?: error("socket == null")
+ socket.soTimeout = SOCKET_SEND_MESSAGE_READ_TIMEOUT
+
+ val msgOut = EOLConvertingOutputStream(
+ LineWrapOutputStream(
+ SmtpDataStuffing(outputStream),
+ 1000
+ )
+ )
+
+ message.writeTo(msgOut)
+ msgOut.endWithCrLfAndFlush()
+
+ // After the "\r\n." is attempted, we may have sent the message
+ entireMessageSent = true
+ executeCommand(".")
+ } catch (e: NegativeSmtpReplyException) {
+ throw e
+ } catch (e: Exception) {
+ throw MessagingException("Unable to send message", entireMessageSent, e)
+ } finally {
+ close()
+ }
+ }
+
+ private fun constructSmtpMailFromCommand(from: Array<Address>, is8bitEncodingAllowed: Boolean): String {
+ val fromAddress = from.first().address
+ return if (is8bitEncodingAllowed) {
+ String.format("MAIL FROM:<%s> BODY=8BITMIME", fromAddress)
+ } else {
+ Timber.d("Server does not support 8-bit transfer encoding")
+ String.format("MAIL FROM:<%s>", fromAddress)
+ }
+ }
+
+ private fun ensureClosed() {
+ if (inputStream != null || outputStream != null || socket != null || responseParser != null) {
+ Timber.w(RuntimeException(), "SmtpTransport was open when it was expected to be closed")
+ close()
+ }
+ }
+
+ private fun close() {
+ writeQuitCommand()
+
+ IOUtils.closeQuietly(inputStream)
+ IOUtils.closeQuietly(outputStream)
+ IOUtils.closeQuietly(socket)
+
+ inputStream = null
+ responseParser = null
+ outputStream = null
+ socket = null
+ }
+
+ private fun writeQuitCommand() {
+ try {
+ // We don't care about the server's response to the QUIT command
+ writeLine("QUIT")
+ } catch (ignored: Exception) {
+ }
+ }
+
+ private fun writeLine(command: String, sensitive: Boolean = false) {
+ if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_SMTP) {
+ val commandToLog = if (sensitive && !K9MailLib.isDebugSensitive()) {
+ "SMTP >>> *sensitive*"
+ } else {
+ "SMTP >>> $command"
+ }
+ Timber.d(commandToLog)
+ }
+
+ // Important: Send command + CRLF using just one write() call. Using multiple calls might result in multiple
+ // TCP packets being sent and some SMTP servers misbehave if CR and LF arrive in separate packets.
+ // See https://code.google.com/archive/p/k9mail/issues/799
+ val data = (command + "\r\n").toByteArray()
+ outputStream!!.apply {
+ write(data)
+ flush()
+ }
+ }
+
+ private fun executeSensitiveCommand(format: String, vararg args: Any): SmtpResponse {
+ return executeCommand(sensitive = true, format, *args)
+ }
+
+ private fun executeCommand(format: String, vararg args: Any): SmtpResponse {
+ return executeCommand(sensitive = false, format, *args)
+ }
+
+ private fun executeCommand(sensitive: Boolean, format: String, vararg args: Any): SmtpResponse {
+ val command = String.format(Locale.ROOT, format, *args)
+ writeLine(command, sensitive)
+
+ val response = responseParser!!.readResponse(isEnhancedStatusCodesProvided)
+ logResponse(response, sensitive)
+
+ if (response.isNegativeResponse) {
+ throw buildNegativeSmtpReplyException(response)
+ }
+
+ return response
+ }
+
+ private fun buildNegativeSmtpReplyException(response: SmtpResponse): NegativeSmtpReplyException {
+ return NegativeSmtpReplyException(
+ replyCode = response.replyCode,
+ replyText = response.joinedText,
+ enhancedStatusCode = response.enhancedStatusCode
+ )
+ }
+
+ private fun executePipelinedCommands(pipelinedCommands: List<String>) {
+ for (command in pipelinedCommands) {
+ writeLine(command, false)
+ }
+ }
+
+ private fun readPipelinedResponse(pipelinedCommands: List<String>) {
+ val responseParser = responseParser!!
+ var firstException: MessagingException? = null
+
+ repeat(pipelinedCommands.size) {
+ val response = responseParser.readResponse(isEnhancedStatusCodesProvided)
+ logResponse(response)
+
+ if (response.isNegativeResponse && firstException == null) {
+ firstException = buildNegativeSmtpReplyException(response)
+ }
+ }
+
+ firstException?.let {
+ throw it
+ }
+ }
+
+ private fun saslAuthLogin() {
+ try {
+ executeCommand("AUTH LOGIN")
+ executeSensitiveCommand(Base64.encode(username))
+ executeSensitiveCommand(Base64.encode(password))
+ } catch (exception: NegativeSmtpReplyException) {
+ if (exception.replyCode == SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
+ throw AuthenticationFailedException("AUTH LOGIN failed (${exception.message})")
+ } else {
+ throw exception
+ }
+ }
+ }
+
+ private fun saslAuthPlain() {
+ val data = Base64.encode("\u0000" + username + "\u0000" + password)
+ try {
+ executeSensitiveCommand("AUTH PLAIN %s", data)
+ } catch (exception: NegativeSmtpReplyException) {
+ if (exception.replyCode == SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
+ throw AuthenticationFailedException("AUTH PLAIN failed (${exception.message})")
+ } else {
+ throw exception
+ }
+ }
+ }
+
+ private fun saslAuthCramMD5() {
+ val respList = executeCommand("AUTH CRAM-MD5").texts
+ if (respList.size != 1) {
+ throw MessagingException("Unable to negotiate CRAM-MD5")
+ }
+
+ val b64Nonce = respList[0]
+ val b64CRAMString = Authentication.computeCramMd5(username, password, b64Nonce)
+ try {
+ executeSensitiveCommand(b64CRAMString)
+ } catch (exception: NegativeSmtpReplyException) {
+ if (exception.replyCode == SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
+ throw AuthenticationFailedException(exception.message!!, exception)
+ } else {
+ throw exception
+ }
+ }
+ }
+
+ private fun saslOAuth(method: OAuthMethod) {
+ retryOAuthWithNewToken = true
+ try {
+ attempOAuth(method, username)
+ } catch (negativeResponse: NegativeSmtpReplyException) {
+ if (negativeResponse.replyCode != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
+ throw negativeResponse
+ }
+
+ oauthTokenProvider!!.invalidateToken()
+
+ if (!retryOAuthWithNewToken) {
+ handlePermanentFailure(negativeResponse)
+ } else {
+ handleTemporaryFailure(method, username, negativeResponse)
+ }
+ }
+ }
+
+ private fun handlePermanentFailure(negativeResponse: NegativeSmtpReplyException): Nothing {
+ throw AuthenticationFailedException(negativeResponse.message!!, negativeResponse)
+ }
+
+ private fun handleTemporaryFailure(
+ method: OAuthMethod,
+ username: String,
+ negativeResponseFromOldToken: NegativeSmtpReplyException
+ ) {
+ // Token was invalid. We could avoid this double check if we had a reasonable chance of knowing if a token was
+ // invalid before use (e.g. due to expiry). But we don't. This is the intended behaviour per AccountManager.
+ Timber.v(negativeResponseFromOldToken, "Authentication exception, re-trying with new token")
+
+ try {
+ attempOAuth(method, username)
+ } catch (negativeResponseFromNewToken: NegativeSmtpReplyException) {
+ if (negativeResponseFromNewToken.replyCode != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
+ throw negativeResponseFromNewToken
+ }
+
+ // Okay, we failed on a new token. Invalidate the token anyway but assume it's permanent.
+ Timber.v(negativeResponseFromNewToken, "Authentication exception for new token, permanent error assumed")
+
+ oauthTokenProvider!!.invalidateToken()
+ handlePermanentFailure(negativeResponseFromNewToken)
+ }
+ }
+
+ private fun attempOAuth(method: OAuthMethod, username: String) {
+ val token = oauthTokenProvider!!.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong())
+ val authString = method.buildInitialClientResponse(username, token)
+
+ val response = executeSensitiveCommand("%s %s", method.command, authString)
+ if (response.replyCode == SMTP_CONTINUE_REQUEST) {
+ val replyText = response.joinedText
+ retryOAuthWithNewToken = XOAuth2ChallengeParser.shouldRetry(replyText, host)
+
+ // Per Google spec, respond to challenge with empty response
+ executeCommand("")
+ }
+ }
+
+ private fun saslAuthExternal() {
+ executeCommand("AUTH EXTERNAL %s", Base64.encode(username))
+ }
+
+ @Throws(MessagingException::class)
+ fun checkSettings() {
+ ensureClosed()
+
+ try {
+ open()
+ } finally {
+ close()
+ }
+ }
+}
+
+private enum class OAuthMethod {
+ XOAUTH2 {
+ override val command = "AUTH XOAUTH2"
+
+ override fun buildInitialClientResponse(username: String, token: String): String {
+ return Authentication.computeXoauth(username, token)
+ }
+ },
+ OAUTHBEARER {
+ override val command = "AUTH OAUTHBEARER"
+
+ override fun buildInitialClientResponse(username: String, token: String): String {
+ return buildOAuthBearerInitialClientResponse(username, token)
+ }
+ };
+
+ abstract val command: String
+ abstract fun buildInitialClientResponse(username: String, token: String): String
+}
diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/StatusCodeClass.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/StatusCodeClass.kt
new file mode 100644
index 0000000..2fe0097
--- /dev/null
+++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/StatusCodeClass.kt
@@ -0,0 +1,7 @@
+package com.fsck.k9.mail.transport.smtp
+
+enum class StatusCodeClass(val codeClass: Int) {
+ SUCCESS(2),
+ PERSISTENT_TRANSIENT_FAILURE(4),
+ PERMANENT_FAILURE(5);
+}