diff options
| author | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
|---|---|---|
| committer | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
| commit | 50ce5d5e6cd0d720ff437d35eace545816848428 (patch) | |
| tree | b59af206a36265cf081945e434900b32b0c43edd /mail/protocols/smtp/src/main/java/com | |
| download | monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2 monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip | |
Inital
Diffstat (limited to 'mail/protocols/smtp/src/main/java/com')
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); +} |
