Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
22
mail/protocols/smtp/build.gradle.kts
Normal file
22
mail/protocols/smtp/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.fsck.k9.mail.transport.smtp
|
||||
|
||||
data class EnhancedStatusCode(
|
||||
val statusClass: StatusCodeClass,
|
||||
val subject: Int,
|
||||
val detail: Int,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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?)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.fsck.k9.mail.transport.smtp
|
||||
|
||||
class SmtpResponseParserException(message: String) : RuntimeException(message)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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]")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue