Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,22 @@
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.android.lint)
}
val testCoverageEnabled: Boolean by extra
if (testCoverageEnabled) {
apply(plugin = "jacoco")
}
dependencies {
api(projects.mail.common)
implementation(projects.core.common)
implementation(libs.commons.io)
implementation(libs.okio)
testImplementation(projects.core.logging.testing)
testImplementation(projects.mail.testing)
testImplementation(libs.okio)
testImplementation(libs.jzlib)
}

View file

@ -0,0 +1,7 @@
package com.fsck.k9.mail.transport.smtp
data class EnhancedStatusCode(
val statusClass: StatusCodeClass,
val subject: Int,
val detail: Int,
)

View file

@ -0,0 +1,23 @@
package com.fsck.k9.mail.transport.smtp
import net.thunderbird.core.common.exception.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
}

View file

@ -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
}

View file

@ -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?)
}

View file

@ -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)
}
}
}

View file

@ -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: %s", 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'
}

View file

@ -0,0 +1,3 @@
package com.fsck.k9.mail.transport.smtp
class SmtpResponseParserException(message: String) : RuntimeException(message)

View file

@ -0,0 +1,69 @@
package com.fsck.k9.mail.transport.smtp
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.CertificateValidationException
import com.fsck.k9.mail.ClientCertificateError.CertificateExpired
import com.fsck.k9.mail.ClientCertificateError.RetrievalFailure
import com.fsck.k9.mail.ClientCertificateException
import com.fsck.k9.mail.MissingCapabilityException
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mail.oauth.AuthStateStorage
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
import com.fsck.k9.mail.oauth.OAuth2TokenProviderFactory
import com.fsck.k9.mail.server.ServerSettingsValidationResult
import com.fsck.k9.mail.server.ServerSettingsValidationResult.ClientCertificateError
import com.fsck.k9.mail.server.ServerSettingsValidator
import com.fsck.k9.mail.ssl.TrustedSocketFactory
import java.io.IOException
import net.thunderbird.core.common.exception.MessagingException
class SmtpServerSettingsValidator(
private val trustedSocketFactory: TrustedSocketFactory,
private val oAuth2TokenProviderFactory: OAuth2TokenProviderFactory?,
) : ServerSettingsValidator {
@Suppress("TooGenericExceptionCaught")
override fun checkServerSettings(
serverSettings: ServerSettings,
authStateStorage: AuthStateStorage?,
): ServerSettingsValidationResult {
val oAuth2TokenProvider = createOAuth2TokenProviderOrNull(authStateStorage)
val smtpTransport = SmtpTransport(serverSettings, trustedSocketFactory, oAuth2TokenProvider)
return try {
smtpTransport.checkSettings()
ServerSettingsValidationResult.Success
} catch (e: AuthenticationFailedException) {
ServerSettingsValidationResult.AuthenticationError(e.messageFromServer)
} catch (e: CertificateValidationException) {
ServerSettingsValidationResult.CertificateError(e.certificateChain)
} catch (e: NegativeSmtpReplyException) {
ServerSettingsValidationResult.ServerError(e.replyText)
} catch (e: MissingCapabilityException) {
ServerSettingsValidationResult.MissingServerCapabilityError(e.capabilityName)
} catch (e: ClientCertificateException) {
when (e.error) {
RetrievalFailure -> ClientCertificateError.ClientCertificateRetrievalFailure
CertificateExpired -> ClientCertificateError.ClientCertificateExpired
}
} catch (e: MessagingException) {
val cause = e.cause
if (cause is IOException) {
ServerSettingsValidationResult.NetworkError(cause)
} else {
ServerSettingsValidationResult.UnknownError(e)
}
} catch (e: IOException) {
ServerSettingsValidationResult.NetworkError(e)
} catch (e: Exception) {
ServerSettingsValidationResult.UnknownError(e)
}
}
private fun createOAuth2TokenProviderOrNull(authStateStorage: AuthStateStorage?): OAuth2TokenProvider? {
return authStateStorage?.let {
oAuth2TokenProviderFactory?.create(it)
}
}
}

View file

@ -0,0 +1,679 @@
package com.fsck.k9.mail.transport.smtp
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.MissingCapabilityException
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.CertificateChainExtractor
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.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.net.UnknownHostException
import java.security.GeneralSecurityException
import java.util.Locale
import javax.net.ssl.SSLException
import net.thunderbird.core.common.exception.MessagingException
import net.thunderbird.core.logging.legacy.Log
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
// We use "ehlo.thunderbird.net" for privacy reasons,
// see https://ehlo.thunderbird.net/
public const val SMTP_HELLO_NAME = "ehlo.thunderbird.net"
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 areUnicodeAddressesAllowed = 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?) {
Log.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()
var extensions = sendHello(SMTP_HELLO_NAME)
is8bitEncodingAllowed = extensions.containsKey("8BITMIME")
areUnicodeAddressesAllowed = extensions.containsKey("SMTPUTF8")
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(SMTP_HELLO_NAME)
secureConnection = true
} else {
throw MissingCapabilityException("STARTTLS")
}
}
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"])
when (authType) {
AuthType.NONE -> {
// The outgoing server is configured to not use any authentication. So do nothing.
}
AuthType.PLAIN -> {
// try saslAuthPlain first, because it supports UTF-8 explicitly
if (authPlainSupported) {
saslAuthPlain()
} else if (authLoginSupported) {
saslAuthLogin()
} else {
throw MissingCapabilityException("AUTH PLAIN")
}
}
AuthType.CRAM_MD5 -> {
if (authCramMD5Supported) {
saslAuthCramMD5()
} else {
throw MissingCapabilityException("AUTH CRAM-MD5")
}
}
AuthType.XOAUTH2 -> {
if (oauthTokenProvider == null) {
throw MessagingException("No OAuth2TokenProvider available.")
} else if (authOAuthBearerSupported) {
saslOAuth(OAuthMethod.OAUTHBEARER)
} else if (authXoauth2Supported) {
saslOAuth(OAuthMethod.XOAUTH2)
} else {
throw MissingCapabilityException("AUTH OAUTHBEARER")
}
}
AuthType.EXTERNAL -> {
if (authExternalSupported) {
saslAuthExternal()
} else {
throw MissingCapabilityException("AUTH EXTERNAL")
}
}
else -> {
throw MessagingException("Unhandled authentication method found in server settings (bug).")
}
}
} catch (e: MessagingException) {
close()
throw e
} catch (e: SSLException) {
close()
val certificateChain = CertificateChainExtractor.extract(e)
if (certificateChain != null) {
throw CertificateValidationException(certificateChain, 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) {
Log.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) {
Log.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)
if (smtpResponse.isNegativeResponse) {
throw buildNegativeSmtpReplyException(smtpResponse)
}
}
private fun logResponse(smtpResponse: SmtpResponse, sensitive: Boolean = false) {
if (K9MailLib.isDebug()) {
val omitText = sensitive && !K9MailLib.isDebugSensitive()
Log.v("%s", smtpResponse.toLogString(omitText, linePrefix = "SMTP <<< "))
}
}
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) {
Log.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()) {
Log.v("Server doesn't support the EHLO command. Trying HELO...")
}
try {
executeCommand("HELO %s", host)
} catch (e: NegativeSmtpReplyException) {
Log.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,
message.usesAnyUnicodeAddresses(),
)
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,
canUseSmtputf8: Boolean,
): String {
val fromAddress = from.first().address
val smtputf8 = if (areUnicodeAddressesAllowed && canUseSmtputf8) " SMTPUTF8" else ""
val eightbit = if (is8bitEncodingAllowed) " BODY=8BITMIME" else ""
return String.format(Locale.US, "MAIL FROM:<%s>%s%s", fromAddress, smtputf8, eightbit)
}
private fun ensureClosed() {
if (inputStream != null || outputStream != null || socket != null || responseParser != null) {
Log.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"
}
Log.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) {
handlePossibleAuthenticationFailure("AUTH LOGIN", exception)
}
}
private fun saslAuthPlain() {
val data = Base64.encode("\u0000" + username + "\u0000" + password)
try {
executeSensitiveCommand("AUTH PLAIN %s", data)
} catch (exception: NegativeSmtpReplyException) {
handlePossibleAuthenticationFailure("AUTH PLAIN", 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) {
handlePossibleAuthenticationFailure("AUTH CRAM-MD5", exception)
}
}
private fun saslOAuth(method: OAuthMethod) {
Log.d("saslOAuth() called with: method = $method")
retryOAuthWithNewToken = true
val primaryEmail = oauthTokenProvider?.primaryEmail
val primaryUsername = primaryEmail ?: username
try {
attempOAuth(method, primaryUsername)
} catch (negativeResponse: NegativeSmtpReplyException) {
Log.w(negativeResponse, "saslOAuth: failed to authenticate.")
if (negativeResponse.replyCode != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
throw negativeResponse
}
oauthTokenProvider!!.invalidateToken()
if (!retryOAuthWithNewToken) {
handlePermanentOAuthFailure(method, negativeResponse)
} else {
handleTemporaryOAuthFailure(method, primaryUsername, negativeResponse)
}
}
}
private fun handlePermanentOAuthFailure(
method: OAuthMethod,
negativeResponse: NegativeSmtpReplyException,
): Nothing {
throw AuthenticationFailedException(
message = "${method.command} failed",
throwable = negativeResponse,
messageFromServer = negativeResponse.replyText,
)
}
private fun handleTemporaryOAuthFailure(
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.
Log.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.
Log.v(negativeResponseFromNewToken, "Authentication exception for new token, permanent error assumed")
oauthTokenProvider!!.invalidateToken()
handlePermanentOAuthFailure(method, 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))
}
private fun handlePossibleAuthenticationFailure(
authenticationMethod: String,
negativeResponse: NegativeSmtpReplyException,
): Nothing {
if (negativeResponse.replyCode == SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
throw AuthenticationFailedException(
message = "$authenticationMethod failed",
throwable = negativeResponse,
messageFromServer = negativeResponse.replyText,
)
} else {
throw negativeResponse
}
}
@Suppress("TooGenericExceptionCaught")
@Throws(MessagingException::class)
fun checkSettings() {
ensureClosed()
try {
open()
} catch (e: Exception) {
Log.e(e, "Error while checking server settings")
throw e
} 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
}

View file

@ -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),
}

View file

@ -0,0 +1,425 @@
package com.fsck.k9.mail.transport.mockServer;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.util.Deque;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;
import com.fsck.k9.mail.testing.security.TestKeyStoreProvider;
import com.jcraft.jzlib.JZlib;
import com.jcraft.jzlib.ZOutputStream;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import okio.BufferedSink;
import okio.BufferedSource;
import okio.Okio;
import org.apache.commons.io.IOUtils;
public class MockSmtpServer {
private static final byte[] CRLF = { '\r', '\n' };
private final Deque<SmtpInteraction> interactions = new ConcurrentLinkedDeque<>();
private final CountDownLatch waitForConnectionClosed = new CountDownLatch(1);
private final CountDownLatch waitForAllExpectedCommands = new CountDownLatch(1);
private final TestKeyStoreProvider keyStoreProvider;
private final Logger logger;
private MockServerThread mockServerThread;
private String host;
private int port;
public MockSmtpServer() {
this(TestKeyStoreProvider.INSTANCE, new DefaultLogger());
}
public MockSmtpServer(TestKeyStoreProvider keyStoreProvider, Logger logger) {
this.keyStoreProvider = keyStoreProvider;
this.logger = logger;
}
public void output(String response) {
checkServerNotRunning();
interactions.add(new CannedResponse(response));
}
public void expect(String command) {
checkServerNotRunning();
interactions.add(new ExpectedCommand(command));
}
public void startTls() {
checkServerNotRunning();
interactions.add(new UpgradeToTls());
}
public void closeConnection() {
checkServerNotRunning();
interactions.add(new CloseConnection());
}
public void start() throws IOException {
checkServerNotRunning();
InetAddress localAddress = InetAddress.getByName(null);
ServerSocket serverSocket = new ServerSocket(0, 1, localAddress);
InetSocketAddress localSocketAddress = (InetSocketAddress) serverSocket.getLocalSocketAddress();
host = localSocketAddress.getHostString();
port = serverSocket.getLocalPort();
mockServerThread = new MockServerThread(serverSocket, interactions, waitForConnectionClosed,
waitForAllExpectedCommands, logger, keyStoreProvider);
mockServerThread.start();
}
public void shutdown() {
checkServerRunning();
mockServerThread.shouldStop();
waitForMockServerThread();
}
private void waitForMockServerThread() {
try {
mockServerThread.join(500L);
} catch (InterruptedException ignored) {
}
}
public String getHost() {
checkServerRunning();
return host;
}
public int getPort() {
checkServerRunning();
return port;
}
public void waitForInteractionToComplete() {
checkServerRunning();
try {
waitForAllExpectedCommands.await(1000L, TimeUnit.MILLISECONDS);
} catch (InterruptedException ignored) {
}
}
public void verifyInteractionCompleted() {
shutdown();
if (!interactions.isEmpty()) {
throw new AssertionError("Interactions left: " + interactions.size());
}
UnexpectedCommandException unexpectedCommandException = mockServerThread.getUnexpectedCommandException();
if (unexpectedCommandException != null) {
throw new AssertionError(unexpectedCommandException.getMessage(), unexpectedCommandException);
}
}
public void verifyConnectionNeverCreated() {
checkServerRunning();
if (mockServerThread.clientConnectionCreated()) {
throw new AssertionError("Connection created when it shouldn't have been");
}
}
public void verifyConnectionStillOpen() {
checkServerRunning();
if (mockServerThread.isClientConnectionClosed()) {
throw new AssertionError("Connection closed when it shouldn't be");
}
}
public void verifyConnectionClosed() {
checkServerRunning();
try {
waitForConnectionClosed.await(300L, TimeUnit.MILLISECONDS);
} catch (InterruptedException ignored) {
}
if (!mockServerThread.isClientConnectionClosed()) {
throw new AssertionError("Connection open when is shouldn't be");
}
}
private void checkServerRunning() {
if (mockServerThread == null) {
throw new IllegalStateException("Server was never started");
}
}
private void checkServerNotRunning() {
if (mockServerThread != null) {
throw new IllegalStateException("Server was already started");
}
}
public interface Logger {
void log(String message);
void log(String format, Object... args);
}
private interface SmtpInteraction {
}
private static class ExpectedCommand implements SmtpInteraction {
private final String command;
public ExpectedCommand(String command) {
this.command = command;
}
public String getCommand() {
return command;
}
}
private static class CannedResponse implements SmtpInteraction {
private final String response;
public CannedResponse(String response) {
this.response = response;
}
public String getResponse() {
return response;
}
}
private static class UpgradeToTls implements SmtpInteraction {
}
private static class CloseConnection implements SmtpInteraction {
}
private static class UnexpectedCommandException extends Exception {
public UnexpectedCommandException(String expectedCommand, String receivedCommand) {
super("Expected <" + expectedCommand + ">, but received <" + receivedCommand + ">");
}
}
private static class MockServerThread extends Thread {
private final ServerSocket serverSocket;
private final Deque<SmtpInteraction> interactions;
private final CountDownLatch waitForConnectionClosed;
private final CountDownLatch waitForAllExpectedCommands;
private final Logger logger;
private final TestKeyStoreProvider keyStoreProvider;
private volatile boolean shouldStop = false;
private volatile Socket clientSocket;
private BufferedSource input;
private BufferedSink output;
private volatile UnexpectedCommandException unexpectedCommandException;
public MockServerThread(ServerSocket serverSocket, Deque<SmtpInteraction> interactions,
CountDownLatch waitForConnectionClosed, CountDownLatch waitForAllExpectedCommands, Logger logger,
TestKeyStoreProvider keyStoreProvider) {
super("MockSmtpServer");
this.serverSocket = serverSocket;
this.interactions = interactions;
this.waitForConnectionClosed = waitForConnectionClosed;
this.waitForAllExpectedCommands = waitForAllExpectedCommands;
this.logger = logger;
this.keyStoreProvider = keyStoreProvider;
}
@Override
public void run() {
String hostAddress = serverSocket.getInetAddress().getHostAddress();
int port = serverSocket.getLocalPort();
logger.log("Listening on %s:%d", hostAddress, port);
Socket socket = null;
try {
socket = acceptConnectionAndCloseServerSocket();
clientSocket = socket;
String remoteHostAddress = socket.getInetAddress().getHostAddress();
int remotePort = socket.getPort();
logger.log("Accepted connection from %s:%d", remoteHostAddress, remotePort);
input = Okio.buffer(Okio.source(socket));
output = Okio.buffer(Okio.sink(socket));
while (!shouldStop && !interactions.isEmpty()) {
handleInteractions(socket);
}
waitForAllExpectedCommands.countDown();
while (!shouldStop) {
readAdditionalCommands();
}
waitForConnectionClosed.countDown();
} catch (UnexpectedCommandException e) {
unexpectedCommandException = e;
} catch (IOException e) {
if (!shouldStop) {
logger.log("Exception: %s", e);
}
} catch (KeyStoreException | CertificateException | UnrecoverableKeyException |
NoSuchAlgorithmException | KeyManagementException e) {
throw new RuntimeException(e);
}
IOUtils.closeQuietly(socket);
logger.log("Exiting");
}
private void handleInteractions(Socket socket) throws IOException, KeyStoreException,
NoSuchAlgorithmException, CertificateException, UnrecoverableKeyException, KeyManagementException,
UnexpectedCommandException {
SmtpInteraction interaction = interactions.pop();
if (interaction instanceof ExpectedCommand) {
readExpectedCommand((ExpectedCommand) interaction);
} else if (interaction instanceof CannedResponse) {
writeCannedResponse((CannedResponse) interaction);
} else if (interaction instanceof UpgradeToTls) {
upgradeToTls(socket);
} else if (interaction instanceof CloseConnection) {
clientSocket.close();
}
}
private void readExpectedCommand(ExpectedCommand expectedCommand) throws IOException,
UnexpectedCommandException {
String command = input.readUtf8Line();
if (command == null) {
throw new EOFException();
}
logger.log("C: %s", command);
String expected = expectedCommand.getCommand();
if (!command.equals(expected)) {
logger.log("EXPECTED: %s", expected);
logger.log("ACTUAL: %s", command);
throw new UnexpectedCommandException(expected, command);
}
}
private void writeCannedResponse(CannedResponse cannedResponse) throws IOException {
String response = cannedResponse.getResponse();
logger.log("S: %s", response);
output.writeUtf8(response);
output.write(CRLF);
output.flush();
}
private void enableCompression(Socket socket) throws IOException {
InputStream inputStream = new InflaterInputStream(socket.getInputStream(), new Inflater(true));
input = Okio.buffer(Okio.source(inputStream));
ZOutputStream outputStream = new ZOutputStream(socket.getOutputStream(), JZlib.Z_BEST_SPEED, true);
outputStream.setFlushMode(JZlib.Z_PARTIAL_FLUSH);
output = Okio.buffer(Okio.sink(outputStream));
}
private void upgradeToTls(Socket socket) throws KeyStoreException, IOException, NoSuchAlgorithmException,
CertificateException, UnrecoverableKeyException, KeyManagementException {
KeyStore keyStore = keyStoreProvider.getKeyStore();
String defaultAlgorithm = KeyManagerFactory.getDefaultAlgorithm();
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(defaultAlgorithm);
keyManagerFactory.init(keyStore, keyStoreProvider.getPassword());
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(keyManagerFactory.getKeyManagers(), null, null);
SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(
socket, socket.getInetAddress().getHostAddress(), socket.getPort(), true);
sslSocket.setUseClientMode(false);
sslSocket.startHandshake();
input = Okio.buffer(Okio.source(sslSocket.getInputStream()));
output = Okio.buffer(Okio.sink(sslSocket.getOutputStream()));
}
private void readAdditionalCommands() throws IOException {
String command = input.readUtf8Line();
if (command == null) {
throw new EOFException();
}
logger.log("Received additional command: %s", command);
}
private Socket acceptConnectionAndCloseServerSocket() throws IOException {
Socket socket = serverSocket.accept();
serverSocket.close();
return socket;
}
public void shouldStop() {
shouldStop = true;
IOUtils.closeQuietly(clientSocket);
}
public boolean clientConnectionCreated() {
return clientSocket != null;
}
public boolean isClientConnectionClosed() {
return clientSocket.isClosed();
}
public UnexpectedCommandException getUnexpectedCommandException() {
return unexpectedCommandException;
}
}
private static class DefaultLogger implements Logger {
@Override
public void log(String message) {
System.out.println("MockSmtpServer: " + message);
}
@Override
public void log(String format, Object... args) {
log(String.format(format, args));
}
}
}

View file

@ -0,0 +1,719 @@
package com.fsck.k9.mail.transport.smtp
import assertk.all
import assertk.assertFailure
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.hasMessage
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import assertk.assertions.key
import assertk.assertions.prop
import com.fsck.k9.mail.filter.PeekableInputStream
import com.fsck.k9.mail.testing.crlf
import org.junit.Test
class SmtpResponseParserTest {
private val logger = TestSmtpLogger()
@Test
fun `read greeting`() {
val input = "220 smtp.domain.example ESMTP ready".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readGreeting()
assertThat(response.replyCode).isEqualTo(220)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("smtp.domain.example ESMTP ready")
assertInputExhausted(input)
}
@Test
fun `read multi-line greeting`() {
val input = """
220-Greetings, stranger
220 smtp.domain.example ESMTP ready
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readGreeting()
assertThat(response.replyCode).isEqualTo(220)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("Greetings, stranger", "smtp.domain.example ESMTP ready")
assertInputExhausted(input)
}
@Test
fun `read EHLO response`() {
val input = """
250-smtp.domain.example greets 127.0.0.1
250-PIPELINING
250-ENHANCEDSTATUSCODES
250-8BITMIME
250-SIZE 104857600
250-DELIVERBY
250-AUTH PLAIN LOGIN CRAM-MD5 DIGEST-MD5
250 help
""".trimIndent()
val inputStream = input.toPeekableInputStream()
val parser = SmtpResponseParser(logger, inputStream)
val response = parser.readHelloResponse()
assertThat(response).isInstanceOf<SmtpHelloResponse.Hello>().all {
prop(SmtpHelloResponse.Hello::response)
.transform { it.toLogString(false, "") }.isEqualTo(input)
prop(SmtpHelloResponse.Hello::keywords).all {
transform { it.keys }.containsExactlyInAnyOrder(
"PIPELINING",
"ENHANCEDSTATUSCODES",
"8BITMIME",
"SIZE",
"DELIVERBY",
"AUTH",
"HELP",
)
key("PIPELINING").isNotNull().isEmpty()
key("SIZE").isNotNull().containsExactly("104857600")
key("AUTH").isNotNull().containsExactly("PLAIN", "LOGIN", "CRAM-MD5", "DIGEST-MD5")
}
}
assertInputExhausted(inputStream)
}
@Test
fun `read EHLO response with only one line`() {
val input = "250 smtp.domain.example".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readHelloResponse()
assertThat(response).isInstanceOf<SmtpHelloResponse.Hello>().all {
prop(SmtpHelloResponse.Hello::response).all {
prop(SmtpResponse::replyCode).isEqualTo(250)
prop(SmtpResponse::texts).containsExactly("smtp.domain.example")
}
prop(SmtpHelloResponse.Hello::keywords).isEmpty()
}
}
@Test
fun `read EHLO error response`() {
val input = "421 Service not available".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readHelloResponse()
assertThat(response).isInstanceOf<SmtpHelloResponse.Error>()
.prop(SmtpHelloResponse.Error::response).all {
prop(SmtpResponse::replyCode).isEqualTo(421)
prop(SmtpResponse::texts).containsExactly("Service not available")
}
}
@Test
fun `read EHLO response with only reply code`() {
val input = "250".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
assertFailure {
parser.readHelloResponse()
}.isInstanceOf<SmtpResponseParserException>()
.hasMessage("Unexpected character: (13)")
assertThat(logger.logEntries).containsExactly(
LogEntry(
throwable = null,
message = """
SMTP response data on parser error:
250
""".trimIndent(),
),
)
}
@Test
fun `read EHLO response with reply code not matching`() {
val input = """
250-smtp.domain.example
220
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
assertFailure {
parser.readHelloResponse()
}.isInstanceOf<SmtpResponseParserException>()
.hasMessage("Multi-line response with reply codes not matching: 250 != 220")
assertThat(logger.logEntries).containsExactly(
LogEntry(
throwable = null,
message = """
SMTP response data on parser error:
250-smtp.domain.example
220
""".trimIndent(),
),
)
}
@Test
fun `read EHLO response with invalid keywords`() {
val input = """
250-smtp.domain.example
250-SIZE 52428800
250-8BITMIME
250-PIPELINING
250-PIPE_CONNECT
250-AUTH=PLAIN
250-%1 crash when included in format string
250 HELP
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readHelloResponse()
assertThat(response).isInstanceOf<SmtpHelloResponse.Hello>()
.prop(SmtpHelloResponse.Hello::keywords).transform { it.keys }.containsExactlyInAnyOrder(
"SIZE",
"8BITMIME",
"PIPELINING",
"HELP",
)
assertThat(logger.logEntries.map { it.message }).containsExactly(
"Ignoring EHLO keyword line: PIPE_CONNECT",
"Ignoring EHLO keyword line: AUTH=PLAIN",
"Ignoring EHLO keyword line: %1 crash when included in format string",
)
assertThat(logger.logEntries.map { it.throwable?.message }).containsExactly(
"EHLO keyword contains invalid character",
"EHLO keyword contains invalid character",
"EHLO keyword contains invalid character",
)
}
@Test
fun `read EHLO response with empty parameter`() {
val input = """
250-smtp.domain.example
250 KEYWORD${" "}
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readHelloResponse()
assertThat(response).isInstanceOf<SmtpHelloResponse.Hello>()
.transform { it.keywords.keys }.isEmpty()
assertThat(logger.logEntries).isNotNull().hasSize(1)
assertThat(logger.logEntries.first().throwable).isNotNull().hasMessage("EHLO parameter must not be empty")
assertThat(logger.logEntries.first().message).isEqualTo("Ignoring EHLO keyword line: KEYWORD ")
}
@Test
fun `read EHLO response with invalid parameter`() {
val input = """
250-smtp.domain.example
250-8BITMIME
250 KEYWORD para${"\t"}meter
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readHelloResponse()
assertThat(response).isInstanceOf<SmtpHelloResponse.Hello>()
.transform { it.keywords.keys }.containsExactlyInAnyOrder("8BITMIME")
assertThat(logger.logEntries).hasSize(1)
assertThat(logger.logEntries.first().throwable).isNotNull()
.hasMessage("EHLO parameter contains invalid character")
assertThat(logger.logEntries.first().message)
.isEqualTo("Ignoring EHLO keyword line: KEYWORD para${"\t"}meter")
}
@Test
fun `error in EHLO response after successfully reading greeting`() {
val input = """
220 Greeting
INVALID
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
parser.readGreeting()
assertFailure {
parser.readHelloResponse()
}.isInstanceOf<SmtpResponseParserException>()
.hasMessage("Unexpected character: I (73)")
assertThat(logger.logEntries).containsExactly(
LogEntry(
throwable = null,
message = """
SMTP response data on parser error:
I
""".trimIndent(),
),
)
}
@Test
fun `positive response`() {
val input = "200 OK".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = false)
assertThat(response.isNegativeResponse).isFalse()
}
@Test
fun `negative response`() {
val input = "500 Oops".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = false)
assertThat(response.isNegativeResponse).isTrue()
}
@Test
fun `reply code only`() {
val input = "502".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = false)
assertThat(response.replyCode).isEqualTo(502)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).isEmpty()
assertInputExhausted(input)
}
@Test
fun `reply code and text`() {
val input = "250 OK".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = false)
assertThat(response.replyCode).isEqualTo(250)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("OK")
assertInputExhausted(input)
}
@Test
fun `reply code and text with enhanced status code`() {
val input = "250 2.1.0 Originator <sender@domain.example> ok".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = true)
assertThat(response.replyCode).isEqualTo(250)
assertThat(response.enhancedStatusCode).isEqualTo(
EnhancedStatusCode(statusClass = StatusCodeClass.SUCCESS, subject = 1, detail = 0),
)
assertThat(response.texts).containsExactly("Originator <sender@domain.example> ok")
assertInputExhausted(input)
}
@Test
fun `enhancedStatusCodes enabled and 3xx reply code`() {
val input = "354 Ok Send data ending with <CRLF>.<CRLF>".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = true)
assertThat(response.replyCode).isEqualTo(354)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("Ok Send data ending with <CRLF>.<CRLF>")
assertInputExhausted(input)
}
@Test
fun `multi-line response with text`() {
val input = """
500-Line one
500 Line two
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = false)
assertThat(response.replyCode).isEqualTo(500)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("Line one", "Line two")
assertInputExhausted(input)
}
@Test
fun `multi-line response with empty textstring`() {
val input = """
500-
500 Line two
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = false)
assertThat(response.replyCode).isEqualTo(500)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("", "Line two")
assertInputExhausted(input)
}
@Test
fun `multi-line response without text on last line`() {
val input = """
500-Line one
500-Line two
500
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = false)
assertThat(response.replyCode).isEqualTo(500)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("Line one", "Line two")
assertInputExhausted(input)
}
@Test
fun `multi-line response with enhanced status code`() {
val input = """
250-2.1.0 Sender <sender@domain.example>
250 2.1.0 OK
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = true)
assertThat(response.replyCode).isEqualTo(250)
assertThat(response.enhancedStatusCode).isEqualTo(
EnhancedStatusCode(statusClass = StatusCodeClass.SUCCESS, subject = 1, detail = 0),
)
assertThat(response.texts).containsExactly("Sender <sender@domain.example>", "OK")
assertInputExhausted(input)
}
@Test
fun `read multiple responses`() {
val input = """
250 Sender <sender@domain.example> OK
250 Recipient <recipient@domain.example> OK
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val responseOne = parser.readResponse(enhancedStatusCodes = false)
assertThat(responseOne.replyCode).isEqualTo(250)
assertThat(responseOne.enhancedStatusCode).isNull()
assertThat(responseOne.texts).containsExactly("Sender <sender@domain.example> OK")
val responseTwo = parser.readResponse(enhancedStatusCodes = false)
assertThat(responseTwo.replyCode).isEqualTo(250)
assertThat(responseTwo.enhancedStatusCode).isNull()
assertThat(responseTwo.texts).containsExactly("Recipient <recipient@domain.example> OK")
assertInputExhausted(input)
}
@Test
fun `multi-line response with reply codes not matching`() {
val input = """
200-Line one
500 Line two
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
assertFailure {
parser.readResponse(enhancedStatusCodes = false)
}.isInstanceOf<SmtpResponseParserException>()
.hasMessage("Multi-line response with reply codes not matching: 200 != 500")
assertThat(logger.logEntries).containsExactly(
LogEntry(
throwable = null,
message = """
SMTP response data on parser error:
200-Line one
500
""".trimIndent(),
),
)
}
@Test
fun `multi-line response with reply codes not matching and raw protocol logging disabled`() {
val input = """
200-Line one
500 Line two
""".toPeekableInputStream()
val logger = TestSmtpLogger(isRawProtocolLoggingEnabled = false)
val parser = SmtpResponseParser(logger, input)
assertFailure {
parser.readResponse(enhancedStatusCodes = false)
}.isInstanceOf<SmtpResponseParserException>()
.hasMessage("Multi-line response with reply codes not matching: 200 != 500")
assertThat(logger.logEntries).isEmpty()
}
@Test
fun `invalid 1st reply code digit`() {
val input = "611".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
assertFailure {
parser.readResponse(enhancedStatusCodes = false)
}.isInstanceOf<SmtpResponseParserException>()
.hasMessage("Unsupported 1st reply code digit: 6")
}
@Test
fun `invalid 2nd reply code digit should only produce a log entry`() {
val input = "280 Something".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = false)
assertThat(response.replyCode).isEqualTo(280)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("Something")
assertThat(logger.logEntries).containsExactly(
LogEntry(throwable = null, message = "2nd digit of reply code outside of specified range (0..5): 8"),
)
}
@Test
fun `invalid 3rd reply code digit`() {
val input = "20x".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
assertFailure {
parser.readResponse(enhancedStatusCodes = false)
}.isInstanceOf<SmtpResponseParserException>()
.hasMessage("Unexpected character: x (120)")
assertThat(logger.logEntries).containsExactly(
LogEntry(
throwable = null,
message = """
SMTP response data on parser error:
20x
""".trimIndent(),
),
)
}
@Test
fun `end of stream after reply code`() {
val input = PeekableInputStream("200".byteInputStream())
val parser = SmtpResponseParser(logger, input)
assertFailure {
parser.readResponse(enhancedStatusCodes = false)
}.isInstanceOf<SmtpResponseParserException>()
.hasMessage("Unexpected end of stream")
assertThat(logger.logEntries).containsExactly(
LogEntry(
throwable = null,
message = """
SMTP response data on parser error:
200
""".trimIndent(),
),
)
}
@Test
fun `response ending with CR only`() {
val input = PeekableInputStream("200\r".byteInputStream())
val parser = SmtpResponseParser(logger, input)
assertFailure {
parser.readResponse(enhancedStatusCodes = false)
}.isInstanceOf<SmtpResponseParserException>()
.hasMessage("Unexpected end of stream")
}
@Test
fun `response ending with LF only`() {
val input = PeekableInputStream("200\n".byteInputStream())
val parser = SmtpResponseParser(logger, input)
assertFailure {
parser.readResponse(enhancedStatusCodes = false)
}.isInstanceOf<SmtpResponseParserException>()
.hasMessage("Unexpected character: (10)")
assertThat(logger.logEntries).containsExactly(
LogEntry(
throwable = null,
message = """
SMTP response data on parser error:
200
""".trimIndent(),
),
)
}
@Test
fun `reply code with space but without text`() {
val input = "200 ".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = false)
assertThat(response.replyCode).isEqualTo(200)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).isEmpty()
assertInputExhausted(input)
assertThat(logger.logEntries).containsExactly(
LogEntry(throwable = null, message = "'textstring' expected, but CR found instead"),
)
}
@Test
fun `text containing non-ASCII character`() {
val input = "200 über".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = false)
assertThat(response.replyCode).isEqualTo(200)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("über")
assertInputExhausted(input)
assertThat(logger.logEntries).containsExactly(
LogEntry(throwable = null, message = "Text contains characters not allowed in 'textstring'"),
)
}
@Test
fun `enhanced status code class does not match reply code`() {
val input = "250 5.0.0 text".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = true)
assertThat(response.replyCode).isEqualTo(250)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("5.0.0 text")
assertInputExhausted(input)
assertThat(logger.logEntries).hasSize(1)
logger.logEntries.first().let { logEntry ->
assertThat(logEntry.message).isEqualTo("Error parsing enhanced status code")
assertThat(logEntry.throwable?.message).isEqualTo("Reply code doesn't match status code class: 2 != 5")
}
}
@Test
fun `response with invalid enhanced status code subject`() {
val input = "250 2.1000.0 Text".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = true)
assertThat(response.replyCode).isEqualTo(250)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("2.1000.0 Text")
assertInputExhausted(input)
assertThat(logger.logEntries).hasSize(1)
logger.logEntries.first().let { logEntry ->
assertThat(logEntry.message).isEqualTo("Error parsing enhanced status code")
assertThat(logEntry.throwable?.message).isEqualTo("Unexpected character: 0 (48)")
}
}
@Test
fun `response with invalid enhanced status code detail`() {
val input = "250 2.0.1000 Text".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = true)
assertThat(response.replyCode).isEqualTo(250)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("2.0.1000 Text")
assertInputExhausted(input)
assertThat(logger.logEntries).hasSize(1)
logger.logEntries.first().let { logEntry ->
assertThat(logEntry.message).isEqualTo("Error parsing enhanced status code")
assertThat(logEntry.throwable?.message).isEqualTo("Unexpected character: 0 (48)")
}
}
@Test
fun `response with missing enhanced status code`() {
// Yahoo has been observed to send replies without enhanced status code even though the EHLO keyword is present
val input = "550 Request failed; Mailbox unavailable".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = true)
assertThat(response.replyCode).isEqualTo(550)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("Request failed; Mailbox unavailable")
assertInputExhausted(input)
assertThat(logger.logEntries).hasSize(1)
logger.logEntries.first().let { logEntry ->
assertThat(logEntry.message).isEqualTo("Error parsing enhanced status code")
assertThat(logEntry.throwable?.message).isEqualTo("Unexpected character: R (82)")
}
}
@Test
fun `multi-line response with enhanced status code missing in last line`() {
val input = """
550-5.2.1 Request failed
550 Mailbox unavailable
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
assertFailure {
parser.readResponse(enhancedStatusCodes = true)
}.isInstanceOf<SmtpResponseParserException>()
.hasMessage(
"Multi-line response with enhanced status codes not matching: " +
"EnhancedStatusCode(statusClass=PERMANENT_FAILURE, subject=2, detail=1) != null",
)
}
@Test
fun `multi-line response with missing enhanced status code`() {
val input = """
550-Request failed
550 Mailbox unavailable
""".toPeekableInputStream()
val parser = SmtpResponseParser(logger, input)
val response = parser.readResponse(enhancedStatusCodes = true)
assertThat(response.replyCode).isEqualTo(550)
assertThat(response.enhancedStatusCode).isNull()
assertThat(response.texts).containsExactly("Request failed", "Mailbox unavailable")
assertInputExhausted(input)
}
private fun assertInputExhausted(input: PeekableInputStream) {
assertThat(input.read()).isEqualTo(-1)
}
private fun String.toPeekableInputStream(): PeekableInputStream {
return PeekableInputStream((this.trimIndent().crlf() + "\r\n").byteInputStream())
}
}

View file

@ -0,0 +1,173 @@
package com.fsck.k9.mail.transport.smtp
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Test
class SmtpResponseTest {
@Test
fun `log reply code only`() {
val response = SmtpResponse(
replyCode = 200,
enhancedStatusCode = null,
texts = emptyList(),
)
val output = response.toLogString(omitText = false, linePrefix = "SMTP <<< ")
assertThat(output).isEqualTo("SMTP <<< 200")
}
@Test
fun `log reply code only with omitText = true`() {
val response = SmtpResponse(
replyCode = 200,
enhancedStatusCode = null,
texts = emptyList(),
)
val output = response.toLogString(omitText = true, linePrefix = "SMTP <<< ")
assertThat(output).isEqualTo("SMTP <<< 200")
}
@Test
fun `log reply code and text`() {
val response = SmtpResponse(
replyCode = 200,
enhancedStatusCode = null,
texts = listOf("OK"),
)
val output = response.toLogString(omitText = false, linePrefix = "SMTP <<< ")
assertThat(output).isEqualTo("SMTP <<< 200 OK")
}
@Test
fun `log reply code and text with omitText = true`() {
val response = SmtpResponse(
replyCode = 250,
enhancedStatusCode = null,
texts = listOf("Sender <sender@domain.example> OK"),
)
val output = response.toLogString(omitText = true, linePrefix = "SMTP <<< ")
assertThat(output).isEqualTo("SMTP <<< 250 [omitted]")
}
@Test
fun `log reply code and status code`() {
val response = SmtpResponse(
replyCode = 200,
enhancedStatusCode = EnhancedStatusCode(statusClass = StatusCodeClass.SUCCESS, subject = 0, detail = 0),
texts = emptyList(),
)
val output = response.toLogString(omitText = false, linePrefix = "SMTP <<< ")
assertThat(output).isEqualTo("SMTP <<< 200 2.0.0")
}
@Test
fun `log reply code and status code with omitText = true`() {
val response = SmtpResponse(
replyCode = 200,
enhancedStatusCode = EnhancedStatusCode(statusClass = StatusCodeClass.SUCCESS, subject = 0, detail = 0),
texts = emptyList(),
)
val output = response.toLogString(omitText = true, linePrefix = "SMTP <<< ")
assertThat(output).isEqualTo("SMTP <<< 200 2.0.0")
}
@Test
fun `log reply code, status code, and text`() {
val response = SmtpResponse(
replyCode = 200,
enhancedStatusCode = EnhancedStatusCode(statusClass = StatusCodeClass.SUCCESS, subject = 0, detail = 0),
texts = listOf("OK"),
)
val output = response.toLogString(omitText = false, linePrefix = "SMTP <<< ")
assertThat(output).isEqualTo("SMTP <<< 200 2.0.0 OK")
}
@Test
fun `log reply code, status code, and text with omitText = true`() {
val response = SmtpResponse(
replyCode = 200,
enhancedStatusCode = EnhancedStatusCode(statusClass = StatusCodeClass.SUCCESS, subject = 0, detail = 0),
texts = listOf("OK"),
)
val output = response.toLogString(omitText = true, linePrefix = "SMTP <<< ")
assertThat(output).isEqualTo("SMTP <<< 200 2.0.0 [omitted]")
}
@Test
fun `log reply code and multi-line text`() {
val response = SmtpResponse(
replyCode = 250,
enhancedStatusCode = null,
texts = listOf("Sender <sender@domain.example>", "OK"),
)
val output = response.toLogString(omitText = false, linePrefix = "SMTP <<< ")
assertThat(output).isEqualTo(
"""
SMTP <<< 250-Sender <sender@domain.example>
SMTP <<< 250 OK
""".trimIndent(),
)
}
@Test
fun `log reply code and multi-line text with omitText = true`() {
val response = SmtpResponse(
replyCode = 250,
enhancedStatusCode = null,
texts = listOf("Sender <sender@domain.example>", "OK"),
)
val output = response.toLogString(omitText = true, linePrefix = "SMTP <<< ")
assertThat(output).isEqualTo("SMTP <<< 250 [omitted]")
}
@Test
fun `log reply code, status code, and multi-line text`() {
val response = SmtpResponse(
replyCode = 250,
enhancedStatusCode = EnhancedStatusCode(statusClass = StatusCodeClass.SUCCESS, subject = 1, detail = 0),
texts = listOf("Sender <sender@domain.example>", "OK"),
)
val output = response.toLogString(omitText = false, linePrefix = "SMTP <<< ")
assertThat(output).isEqualTo(
"""
SMTP <<< 250-2.1.0 Sender <sender@domain.example>
SMTP <<< 250 2.1.0 OK
""".trimIndent(),
)
}
@Test
fun `log reply code, status code, and multi-line text with omitText = true`() {
val response = SmtpResponse(
replyCode = 250,
enhancedStatusCode = EnhancedStatusCode(statusClass = StatusCodeClass.SUCCESS, subject = 1, detail = 0),
texts = listOf("Sender <sender@domain.example>", "OK"),
)
val output = response.toLogString(omitText = true, linePrefix = "SMTP <<< ")
assertThat(output).isEqualTo("SMTP <<< 250 2.1.0 [omitted]")
}
}

View file

@ -0,0 +1,420 @@
package com.fsck.k9.mail.transport.smtp
import assertk.assertFailure
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.prop
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ClientCertificateError
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mail.oauth.AuthStateStorage
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
import com.fsck.k9.mail.server.ServerSettingsValidationResult
import com.fsck.k9.mail.testing.security.FakeTrustManager
import com.fsck.k9.mail.testing.security.SimpleTrustedSocketFactory
import com.fsck.k9.mail.transport.mockServer.MockSmtpServer
import java.net.UnknownHostException
import kotlin.test.Test
import net.thunderbird.core.logging.legacy.Log
import net.thunderbird.core.logging.testing.TestLogger
import okio.ByteString.Companion.encodeUtf8
import org.junit.Before
private const val USERNAME = "user"
private const val PASSWORD = "password"
private const val AUTHORIZATION_STATE = "auth state"
private const val AUTHORIZATION_TOKEN = "auth-token"
private val CLIENT_CERTIFICATE_ALIAS: String? = null
class SmtpServerSettingsValidatorTest {
private val fakeTrustManager = FakeTrustManager()
private val trustedSocketFactory = SimpleTrustedSocketFactory(fakeTrustManager)
private val serverSettingsValidator = SmtpServerSettingsValidator(
trustedSocketFactory = trustedSocketFactory,
oAuth2TokenProviderFactory = null,
)
@Before
fun setUp() {
Log.logger = TestLogger()
}
@Test
fun `valid server settings with password should return Success`() {
val server = MockSmtpServer().apply {
output("220 localhost Simple Mail Transfer Service Ready")
expect("EHLO " + SMTP_HELLO_NAME)
output("250-localhost Hello " + SMTP_HELLO_NAME)
output("250-ENHANCEDSTATUSCODES")
output("250-AUTH PLAIN LOGIN")
output("250 HELP")
expect("AUTH PLAIN AHVzZXIAcGFzc3dvcmQ=")
output("235 2.7.0 Authentication successful")
expect("QUIT")
closeConnection()
}
server.start()
val serverSettings = ServerSettings(
type = "smtp",
host = server.host,
port = server.port,
connectionSecurity = ConnectionSecurity.NONE,
authenticationType = AuthType.PLAIN,
username = USERNAME,
password = PASSWORD,
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
)
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
assertThat(result).isInstanceOf<ServerSettingsValidationResult.Success>()
server.verifyConnectionClosed()
server.verifyInteractionCompleted()
}
@Test
fun `valid server settings with OAuth should return Success`() {
val serverSettingsValidator = SmtpServerSettingsValidator(
trustedSocketFactory = trustedSocketFactory,
oAuth2TokenProviderFactory = { authStateStorage ->
assertThat(authStateStorage.getAuthorizationState()).isEqualTo(AUTHORIZATION_STATE)
FakeOAuth2TokenProvider()
},
)
val server = MockSmtpServer().apply {
output("220 localhost Simple Mail Transfer Service Ready")
expect("EHLO " + SMTP_HELLO_NAME)
output("250-localhost Hello " + SMTP_HELLO_NAME)
output("250-ENHANCEDSTATUSCODES")
output("250-AUTH PLAIN LOGIN OAUTHBEARER")
output("250 HELP")
expect("AUTH OAUTHBEARER bixhPXVzZXIsAWF1dGg9QmVhcmVyIGF1dGgtdG9rZW4BAQ==")
output("235 2.7.0 Authentication successful")
expect("QUIT")
closeConnection()
}
server.start()
val serverSettings = ServerSettings(
type = "smtp",
host = server.host,
port = server.port,
connectionSecurity = ConnectionSecurity.NONE,
authenticationType = AuthType.XOAUTH2,
username = USERNAME,
password = null,
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
)
val authStateStorage = FakeAuthStateStorage(authorizationState = AUTHORIZATION_STATE)
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage)
assertThat(result).isInstanceOf<ServerSettingsValidationResult.Success>()
server.verifyConnectionClosed()
server.verifyInteractionCompleted()
}
@Test
fun `valid server settings with primary email different from username on OAuth should return Success`() {
// Arrange
val expectedUser = "expected@email.com"
val serverSettingsValidator = SmtpServerSettingsValidator(
trustedSocketFactory = trustedSocketFactory,
oAuth2TokenProviderFactory = { authStateStorage ->
assertThat(authStateStorage.getAuthorizationState()).isEqualTo(AUTHORIZATION_STATE)
FakeOAuth2TokenProvider(primaryEmail = expectedUser)
},
)
val server = MockSmtpServer().apply {
output("220 localhost Simple Mail Transfer Service Ready")
expect("EHLO $SMTP_HELLO_NAME")
output("250-localhost Hello $SMTP_HELLO_NAME")
output("250-ENHANCEDSTATUSCODES")
output("250-AUTH PLAIN LOGIN XOAUTH2")
output("250 HELP")
val ouathBearer = "user=${expectedUser}\u0001auth=Bearer ${AUTHORIZATION_TOKEN}\u0001\u0001"
.encodeUtf8()
.base64()
expect("AUTH XOAUTH2 $ouathBearer")
output("235 2.7.0 Authentication successful")
expect("QUIT")
closeConnection()
}
server.start()
val serverSettings = ServerSettings(
type = "smtp",
host = server.host,
port = server.port,
connectionSecurity = ConnectionSecurity.NONE,
authenticationType = AuthType.XOAUTH2,
username = USERNAME,
password = null,
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
)
val authStateStorage = FakeAuthStateStorage(authorizationState = AUTHORIZATION_STATE)
// Act
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage)
// Assert
assertThat(result).isInstanceOf<ServerSettingsValidationResult.Success>()
server.verifyConnectionClosed()
server.verifyInteractionCompleted()
}
@Test
fun `authentication error should return AuthenticationError`() {
val server = MockSmtpServer().apply {
output("220 localhost Simple Mail Transfer Service Ready")
expect("EHLO " + SMTP_HELLO_NAME)
output("250-localhost Hello " + SMTP_HELLO_NAME)
output("250-ENHANCEDSTATUSCODES")
output("250-AUTH PLAIN LOGIN")
output("250 HELP")
expect("AUTH PLAIN AHVzZXIAcGFzc3dvcmQ=")
output("535 5.7.8 Authentication failed")
expect("QUIT")
closeConnection()
}
server.start()
val serverSettings = ServerSettings(
type = "smtp",
host = server.host,
port = server.port,
connectionSecurity = ConnectionSecurity.NONE,
authenticationType = AuthType.PLAIN,
username = USERNAME,
password = PASSWORD,
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
)
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
assertThat(result).isInstanceOf<ServerSettingsValidationResult.AuthenticationError>()
.prop(ServerSettingsValidationResult.AuthenticationError::serverMessage).isEqualTo("Authentication failed")
server.verifyConnectionClosed()
server.verifyInteractionCompleted()
}
@Test
fun `error code instead of greeting should return ServerError`() {
val server = MockSmtpServer().apply {
output("421 domain.example Service currently not available")
closeConnection()
}
server.start()
val serverSettings = ServerSettings(
type = "smtp",
host = server.host,
port = server.port,
connectionSecurity = ConnectionSecurity.NONE,
authenticationType = AuthType.PLAIN,
username = USERNAME,
password = PASSWORD,
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
)
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
assertThat(result).isInstanceOf<ServerSettingsValidationResult.ServerError>()
server.verifyConnectionClosed()
server.verifyInteractionCompleted()
}
@Test
fun `missing capability should return MissingServerCapabilityError`() {
val server = MockSmtpServer().apply {
output("220 localhost Simple Mail Transfer Service Ready")
expect("EHLO " + SMTP_HELLO_NAME)
output("250-localhost Hello " + SMTP_HELLO_NAME)
output("250 HELP")
expect("QUIT")
closeConnection()
}
server.start()
val serverSettings = ServerSettings(
type = "smtp",
host = server.host,
port = server.port,
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
authenticationType = AuthType.PLAIN,
username = USERNAME,
password = PASSWORD,
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
)
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
assertThat(result).isInstanceOf<ServerSettingsValidationResult.MissingServerCapabilityError>()
.prop(ServerSettingsValidationResult.MissingServerCapabilityError::capabilityName).isEqualTo("STARTTLS")
server.verifyConnectionClosed()
server.verifyInteractionCompleted()
}
@Test
fun `client certificate retrieval failure should return ClientCertificateRetrievalFailure`() {
trustedSocketFactory.injectClientCertificateError(ClientCertificateError.RetrievalFailure)
val server = MockSmtpServer().apply {
output("220 localhost Simple Mail Transfer Service Ready")
expect("EHLO " + SMTP_HELLO_NAME)
output("250-localhost Hello " + SMTP_HELLO_NAME)
output("250-STARTTLS")
output("250 HELP")
expect("STARTTLS")
output("220 Ready to start TLS")
startTls()
}
server.start()
val serverSettings = ServerSettings(
type = "smtp",
host = server.host,
port = server.port,
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
authenticationType = AuthType.PLAIN,
username = USERNAME,
password = PASSWORD,
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
)
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
assertThat(result)
.isInstanceOf<ServerSettingsValidationResult.ClientCertificateError.ClientCertificateRetrievalFailure>()
server.verifyConnectionClosed()
server.verifyInteractionCompleted()
}
@Test
fun `client certificate expired error should return ClientCertificateExpired`() {
trustedSocketFactory.injectClientCertificateError(ClientCertificateError.CertificateExpired)
val server = MockSmtpServer().apply {
output("220 localhost Simple Mail Transfer Service Ready")
expect("EHLO " + SMTP_HELLO_NAME)
output("250-localhost Hello " + SMTP_HELLO_NAME)
output("250-STARTTLS")
output("250 HELP")
expect("STARTTLS")
output("220 Ready to start TLS")
startTls()
}
server.start()
val serverSettings = ServerSettings(
type = "smtp",
host = server.host,
port = server.port,
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
authenticationType = AuthType.PLAIN,
username = USERNAME,
password = PASSWORD,
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
)
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
assertThat(result)
.isInstanceOf<ServerSettingsValidationResult.ClientCertificateError.ClientCertificateExpired>()
server.verifyConnectionClosed()
server.verifyInteractionCompleted()
}
@Test
fun `certificate error when trying to connect should return CertificateError`() {
fakeTrustManager.shouldThrowException = true
val server = MockSmtpServer().apply {
output("220 localhost Simple Mail Transfer Service Ready")
expect("EHLO " + SMTP_HELLO_NAME)
output("250-localhost Hello " + SMTP_HELLO_NAME)
output("250-STARTTLS")
output("250 HELP")
expect("STARTTLS")
output("220 Ready to start TLS")
startTls()
}
server.start()
val serverSettings = ServerSettings(
type = "smtp",
host = server.host,
port = server.port,
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
authenticationType = AuthType.PLAIN,
username = USERNAME,
password = PASSWORD,
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
)
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
assertThat(result).isInstanceOf<ServerSettingsValidationResult.CertificateError>()
.prop(ServerSettingsValidationResult.CertificateError::certificateChain).hasSize(1)
server.verifyConnectionClosed()
server.verifyInteractionCompleted()
}
@Test
fun `non-existent hostname should return NetworkError`() {
val serverSettings = ServerSettings(
type = "smtp",
host = "domain.invalid",
port = 587,
connectionSecurity = ConnectionSecurity.NONE,
authenticationType = AuthType.PLAIN,
username = USERNAME,
password = PASSWORD,
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
)
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
assertThat(result).isInstanceOf<ServerSettingsValidationResult.NetworkError>()
.prop(ServerSettingsValidationResult.NetworkError::exception)
.isInstanceOf<UnknownHostException>()
}
@Test
fun `ServerSettings with wrong type should throw`() {
val serverSettings = ServerSettings(
type = "wrong",
host = "domain.invalid",
port = 587,
connectionSecurity = ConnectionSecurity.NONE,
authenticationType = AuthType.PLAIN,
username = USERNAME,
password = PASSWORD,
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
)
assertFailure {
serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
}.isInstanceOf<IllegalArgumentException>()
}
}
class FakeOAuth2TokenProvider(override val primaryEmail: String? = null) : OAuth2TokenProvider {
override fun getToken(timeoutMillis: Long): String {
return AUTHORIZATION_TOKEN
}
override fun invalidateToken() {
throw UnsupportedOperationException("not implemented")
}
}
class FakeAuthStateStorage(
private var authorizationState: String? = null,
) : AuthStateStorage {
override fun getAuthorizationState(): String? {
return authorizationState
}
override fun updateAuthorizationState(authorizationState: String?) {
this.authorizationState = authorizationState
}
}

View file

@ -0,0 +1,15 @@
package com.fsck.k9.mail.transport.smtp
class TestSmtpLogger(override val isRawProtocolLoggingEnabled: Boolean = true) : SmtpLogger {
val logEntries = mutableListOf<LogEntry>()
override fun log(throwable: Throwable?, message: String, vararg args: Any?) {
val formattedMessage = String.format(message, *args)
logEntries.add(LogEntry(throwable, formattedMessage))
}
}
data class LogEntry(
val throwable: Throwable?,
val message: String,
)