diff options
| author | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
|---|---|---|
| committer | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
| commit | 50ce5d5e6cd0d720ff437d35eace545816848428 (patch) | |
| tree | b59af206a36265cf081945e434900b32b0c43edd /mail/protocols/smtp/src/test/java | |
| download | monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2 monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip | |
Inital
Diffstat (limited to 'mail/protocols/smtp/src/test/java')
5 files changed, 2330 insertions, 0 deletions
diff --git a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/mockServer/MockSmtpServer.java b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/mockServer/MockSmtpServer.java new file mode 100644 index 0000000..35ece7a --- /dev/null +++ b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/mockServer/MockSmtpServer.java @@ -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.helpers.KeyStoreProvider; +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 KeyStoreProvider keyStoreProvider; + private final Logger logger; + + private MockServerThread mockServerThread; + private String host; + private int port; + + + public MockSmtpServer() { + this(KeyStoreProvider.getInstance(), new DefaultLogger()); + } + + public MockSmtpServer(KeyStoreProvider 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 KeyStoreProvider 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, + KeyStoreProvider 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)); + } + } +} diff --git a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserTest.kt b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserTest.kt new file mode 100644 index 0000000..76d1ea0 --- /dev/null +++ b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserTest.kt @@ -0,0 +1,727 @@ +package com.fsck.k9.mail.transport.smtp + +import assertk.all +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.isFailure +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.crlf +import com.fsck.k9.mail.filter.PeekableInputStream +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::class).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::class).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::class) + .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) + + assertThat { + parser.readHelloResponse() + }.isFailure() + .isInstanceOf(SmtpResponseParserException::class) + .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) + + assertThat { + parser.readHelloResponse() + }.isFailure() + .isInstanceOf(SmtpResponseParserException::class) + .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 HELP + """.toPeekableInputStream() + val parser = SmtpResponseParser(logger, input) + + val response = parser.readHelloResponse() + + assertThat(response).isInstanceOf(SmtpHelloResponse.Hello::class) + .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", + ) + assertThat(logger.logEntries.map { it.throwable?.message }).containsExactly( + "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::class) + .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::class) + .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() + + assertThat { + parser.readHelloResponse() + }.isFailure() + .isInstanceOf(SmtpResponseParserException::class) + .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) + + assertThat { + parser.readResponse(enhancedStatusCodes = false) + }.isFailure() + .isInstanceOf(SmtpResponseParserException::class) + .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) + + assertThat { + parser.readResponse(enhancedStatusCodes = false) + }.isFailure() + .isInstanceOf(SmtpResponseParserException::class) + .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) + + assertThat { + parser.readResponse(enhancedStatusCodes = false) + }.isFailure() + .isInstanceOf(SmtpResponseParserException::class) + .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) + + assertThat { + parser.readResponse(enhancedStatusCodes = false) + }.isFailure() + .isInstanceOf(SmtpResponseParserException::class) + .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) + + assertThat { + parser.readResponse(enhancedStatusCodes = false) + }.isFailure() + .isInstanceOf(SmtpResponseParserException::class) + .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) + + assertThat { + parser.readResponse(enhancedStatusCodes = false) + }.isFailure() + .isInstanceOf(SmtpResponseParserException::class) + .hasMessage("Unexpected end of stream") + } + + @Test + fun `response ending with LF only`() { + val input = PeekableInputStream("200\n".byteInputStream()) + val parser = SmtpResponseParser(logger, input) + + assertThat { + parser.readResponse(enhancedStatusCodes = false) + }.isFailure() + .isInstanceOf(SmtpResponseParserException::class) + .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) + + assertThat { + parser.readResponse(enhancedStatusCodes = true) + }.isFailure() + .isInstanceOf(SmtpResponseParserException::class) + .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()) + } +} diff --git a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseTest.kt b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseTest.kt new file mode 100644 index 0000000..1d0b9c9 --- /dev/null +++ b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseTest.kt @@ -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]") + } +} diff --git a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpTransportTest.kt b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpTransportTest.kt new file mode 100644 index 0000000..5d35e5b --- /dev/null +++ b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpTransportTest.kt @@ -0,0 +1,990 @@ +package com.fsck.k9.mail.transport.smtp + +import assertk.all +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isFailure +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import assertk.assertions.prop +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.CertificateValidationException +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.XOAuth2ChallengeParserTest +import com.fsck.k9.mail.filter.Base64 +import com.fsck.k9.mail.helpers.TestMessageBuilder +import com.fsck.k9.mail.helpers.TestTrustedSocketFactory +import com.fsck.k9.mail.internet.MimeMessage +import com.fsck.k9.mail.oauth.OAuth2TokenProvider +import com.fsck.k9.mail.transport.mockServer.MockSmtpServer +import org.junit.Test +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.stubbing + +private const val USERNAME = "user" +private const val PASSWORD = "password" +private val CLIENT_CERTIFICATE_ALIAS: String? = null + +class SmtpTransportTest { + private val socketFactory = TestTrustedSocketFactory.newInstance() + private val oAuth2TokenProvider = createMockOAuth2TokenProvider() + + @Test + fun `open() should provide hostname`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 OK") + } + val transport = startServerAndCreateSmtpTransport(server, password = null) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() without AUTH LOGIN extension should connect without authentication`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 OK") + } + val transport = startServerAndCreateSmtpTransportWithoutPassword(server) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with AUTH PLAIN extension`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH PLAIN LOGIN") + expect("AUTH PLAIN AHVzZXIAcGFzc3dvcmQ=") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.PLAIN) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with AUTH LOGIN extension`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH LOGIN") + expect("AUTH LOGIN") + output("250 OK") + expect("dXNlcg==") + output("250 OK") + expect("cGFzc3dvcmQ=") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.PLAIN) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() without LOGIN and PLAIN AUTH extensions should throw`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH") + expect("QUIT") + output("221 BYE") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.PLAIN) + + assertThat { + transport.open() + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Authentication methods SASL PLAIN and LOGIN are unavailable.") + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with CRAM-MD5 AUTH extension`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH CRAM-MD5") + expect("AUTH CRAM-MD5") + output("334 " + Base64.encode("<24609.1047914046@localhost>")) + expect("dXNlciAyZDBlNTcwYzZlYWI0ZjY3ZDUyZmFkN2Q1NGExZDJhYQ==") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.CRAM_MD5) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() without CRAM-MD5 AUTH extension should throw`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH PLAIN LOGIN") + expect("QUIT") + output("221 BYE") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.CRAM_MD5) + + assertThat { + transport.open() + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Authentication method CRAM-MD5 is unavailable.") + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with OAUTHBEARER method`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH OAUTHBEARER") + expect("AUTH OAUTHBEARER bixhPXVzZXIsAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with OAUTHBEARER method when XOAUTH2 method is also available`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH XOAUTH2 OAUTHBEARER") + expect("AUTH OAUTHBEARER bixhPXVzZXIsAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with XOAUTH2 extension`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH XOAUTH2") + expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with XOAUTH2 extension should throw on 401 response`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH XOAUTH2") + expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=") + output("334 " + XOAuth2ChallengeParserTest.STATUS_401_RESPONSE) + expect("") + output("535-5.7.1 Username and Password not accepted. Learn more at") + output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68") + expect("QUIT") + output("221 BYE") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + assertThat { + transport.open() + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + .hasMessage( + "5.7.1 Username and Password not accepted. Learn more at " + + "5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68", + ) + + inOrder(oAuth2TokenProvider) { + verify(oAuth2TokenProvider).getToken(anyLong()) + verify(oAuth2TokenProvider).invalidateToken() + } + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with XOAUTH2 extension should invalidate and retry on 400 response`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH XOAUTH2") + expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=") + output("334 " + XOAuth2ChallengeParserTest.STATUS_400_RESPONSE) + expect("") + output("535-5.7.1 Username and Password not accepted. Learn more at") + output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68") + expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG5ld1Rva2VuAQE=") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + transport.open() + + inOrder(oAuth2TokenProvider) { + verify(oAuth2TokenProvider).getToken(anyLong()) + verify(oAuth2TokenProvider).invalidateToken() + verify(oAuth2TokenProvider).getToken(anyLong()) + } + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with XOAUTH2 extension should invalidate and retry on invalid JSON response`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH XOAUTH2") + expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=") + output("334 " + XOAuth2ChallengeParserTest.INVALID_RESPONSE) + expect("") + output("535-5.7.1 Username and Password not accepted. Learn more at") + output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68") + expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG5ld1Rva2VuAQE=") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + transport.open() + + inOrder(oAuth2TokenProvider) { + verify(oAuth2TokenProvider).getToken(anyLong()) + verify(oAuth2TokenProvider).invalidateToken() + verify(oAuth2TokenProvider).getToken(anyLong()) + } + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with XOAUTH2 extension should invalidate and retry on missing status JSON response`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH XOAUTH2") + expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=") + output("334 " + XOAuth2ChallengeParserTest.MISSING_STATUS_RESPONSE) + expect("") + output("535-5.7.1 Username and Password not accepted. Learn more at") + output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68") + expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG5ld1Rva2VuAQE=") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + transport.open() + + inOrder(oAuth2TokenProvider) { + verify(oAuth2TokenProvider).getToken(anyLong()) + verify(oAuth2TokenProvider).invalidateToken() + verify(oAuth2TokenProvider).getToken(anyLong()) + } + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with XOAUTH2 extension should throw on multiple failures`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH XOAUTH2") + expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=") + output("334 " + XOAuth2ChallengeParserTest.STATUS_400_RESPONSE) + expect("") + output("535-5.7.1 Username and Password not accepted. Learn more at") + output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68") + expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG5ld1Rva2VuAQE=") + output("334 " + XOAuth2ChallengeParserTest.STATUS_400_RESPONSE) + expect("") + output("535-5.7.1 Username and Password not accepted. Learn more at") + output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68") + expect("QUIT") + output("221 BYE") + } + + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + assertThat { + transport.open() + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + .hasMessage( + "5.7.1 Username and Password not accepted. Learn more at " + + "5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68", + ) + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with XOAUTH2 extension should throw on failure to fetch token`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH XOAUTH2") + expect("QUIT") + output("221 BYE") + } + stubbing(oAuth2TokenProvider) { + on { getToken(anyLong()) } doThrow AuthenticationFailedException("Failed to fetch token") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + assertThat { + transport.open() + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + .hasMessage("Failed to fetch token") + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() without XOAUTH2 extension should throw`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH PLAIN LOGIN") + expect("QUIT") + output("221 BYE") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + assertThat { + transport.open() + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Server doesn't support SASL OAUTHBEARER or XOAUTH2.") + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with AUTH EXTERNAL extension`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH EXTERNAL") + expect("AUTH EXTERNAL dXNlcg==") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.EXTERNAL) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() without AUTH EXTERNAL extension should throw`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH") + expect("QUIT") + output("221 BYE") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.EXTERNAL) + + assertThat { + transport.open() + }.isFailure() + .isInstanceOf(CertificateValidationException::class) + .prop(CertificateValidationException::getReason) + .isEqualTo(CertificateValidationException.Reason.MissingCapability) + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with automatic auth and no transport security and AUTH CRAM-MD5 extension should use CRAM-MD5`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + output("250 AUTH CRAM-MD5") + expect("AUTH CRAM-MD5") + output("334 " + Base64.encode("<24609.1047914046@localhost>")) + expect("dXNlciAyZDBlNTcwYzZlYWI0ZjY3ZDUyZmFkN2Q1NGExZDJhYQ==") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport( + server, + authenticationType = AuthType.AUTOMATIC, + connectionSecurity = ConnectionSecurity.NONE, + ) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with automatic auth and no transport security and AUTH PLAIN extension should throw`() { + val server = MockSmtpServer() + server.output("220 localhost Simple Mail Transfer Service Ready") + server.expect("EHLO [127.0.0.1]") + server.output("250-localhost Hello client.localhost") + server.output("250 AUTH PLAIN LOGIN") + server.expect("QUIT") + server.output("221 BYE") + val transport = startServerAndCreateSmtpTransport( + server, + authenticationType = AuthType.AUTOMATIC, + connectionSecurity = ConnectionSecurity.NONE, + ) + + assertThat { + transport.open() + }.isFailure() + .isInstanceOf(MessagingException::class) + .hasMessage("Update your outgoing server authentication setting. AUTOMATIC authentication is unavailable.") + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with EHLO failing should try HELO`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("502 5.5.1, Unrecognized command.") + expect("HELO [127.0.0.1]") + output("250 localhost") + } + val transport = startServerAndCreateSmtpTransportWithoutPassword(server) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with support for ENHANCEDSTATUSCODES should throw strip enhanced status codes from error message`() { + val server = MockSmtpServer() + server.output("220 localhost Simple Mail Transfer Service Ready") + server.expect("EHLO [127.0.0.1]") + server.output("250-localhost Hello client.localhost") + server.output("250-ENHANCEDSTATUSCODES") + server.output("250 AUTH XOAUTH2") + server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=") + server.output("334 " + XOAuth2ChallengeParserTest.STATUS_401_RESPONSE) + server.expect("") + server.output("535-5.7.1 Username and Password not accepted. Learn more at") + server.output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68") + server.expect("QUIT") + server.output("221 BYE") + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + assertThat { + transport.open() + }.isFailure() + .isInstanceOf(AuthenticationFailedException::class) + .hasMessage( + "Username and Password not accepted. " + + "Learn more at http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68", + ) + + inOrder(oAuth2TokenProvider) { + verify(oAuth2TokenProvider).getToken(anyLong()) + verify(oAuth2TokenProvider).invalidateToken() + } + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with many extensions should parse all`() { + val server = MockSmtpServer().apply { + output("220 smtp.gmail.com ESMTP x25sm19117693wrx.27 - gsmtp") + expect("EHLO [127.0.0.1]") + output("250-smtp.gmail.com at your service, [86.147.34.216]") + output("250-SIZE 35882577") + output("250-8BITMIME") + output("250-AUTH LOGIN PLAIN XOAUTH2 PLAIN-CLIENTTOKEN XOAUTH") + output("250-ENHANCEDSTATUSCODES") + output("250-PIPELINING") + output("250-CHUNKING") + output("250 SMTPUTF8") + expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with STARTTLS`() { + val server = MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello 127.0.0.1") + output("250-STARTTLS") + output("250 HELP") + expect("STARTTLS") + output("220 Ready to start TLS") + startTls() + expect("EHLO [127.0.0.1]") + output("250-localhost Hello 127.0.0.1") + output("250 AUTH PLAIN LOGIN") + expect("AUTH PLAIN AHVzZXIAcGFzc3dvcmQ=") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport( + server, + authenticationType = AuthType.PLAIN, + connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED, + ) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `sendMessage() without address to send to should not open connection`() { + val message = MimeMessage() + val server = createServerAndSetupForPlainAuthentication() + val transport = startServerAndCreateSmtpTransport(server) + + transport.sendMessage(message) + + server.verifyConnectionNeverCreated() + } + + @Test + fun `sendMessage() with single recipient`() { + val message = createDefaultMessage() + val server = createServerAndSetupForPlainAuthentication().apply { + expect("MAIL FROM:<user@localhost>") + output("250 OK") + expect("RCPT TO:<user2@localhost>") + output("250 OK") + expect("DATA") + output("354 End data with <CR><LF>.<CR><LF>") + expect("[message data]") + expect(".") + output("250 OK: queued as 12345") + expect("QUIT") + output("221 BYE") + closeConnection() + } + val transport = startServerAndCreateSmtpTransport(server) + + transport.sendMessage(message) + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `sendMessage() with 8-bit encoding`() { + val message = createDefaultMessage() + val server = createServerAndSetupForPlainAuthentication("8BITMIME").apply { + expect("MAIL FROM:<user@localhost> BODY=8BITMIME") + output("250 OK") + expect("RCPT TO:<user2@localhost>") + output("250 OK") + expect("DATA") + output("354 End data with <CR><LF>.<CR><LF>") + expect("[message data]") + expect(".") + output("250 OK: queued as 12345") + expect("QUIT") + output("221 BYE") + closeConnection() + } + val transport = startServerAndCreateSmtpTransport(server) + + transport.sendMessage(message) + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `sendMessage() with 8-bit encoding extension not case-sensitive`() { + val message = createDefaultMessage() + val server = createServerAndSetupForPlainAuthentication("8bitmime").apply { + expect("MAIL FROM:<user@localhost> BODY=8BITMIME") + output("250 OK") + expect("RCPT TO:<user2@localhost>") + output("250 OK") + expect("DATA") + output("354 End data with <CR><LF>.<CR><LF>") + expect("[message data]") + expect(".") + output("250 OK: queued as 12345") + expect("QUIT") + output("221 BYE") + closeConnection() + } + val transport = startServerAndCreateSmtpTransport(server) + + transport.sendMessage(message) + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `sendMessage() with message too large should throw`() { + val message = createDefaultMessageBuilder() + .setHasAttachments(true) + .messageSize(1234L) + .build() + val server = createServerAndSetupForPlainAuthentication("SIZE 1000") + val transport = startServerAndCreateSmtpTransport(server) + + assertThat { + transport.sendMessage(message) + }.isFailure() + .isInstanceOf(MessagingException::class).all { + hasMessage("Message too large for server") + transform { it.isPermanentFailure }.isTrue() + } + + // FIXME: Make sure connection was closed + // server.verifyConnectionClosed(); + } + + @Test + fun `sendMessage() with negative reply should throw`() { + val message = createDefaultMessage() + val server = createServerAndSetupForPlainAuthentication().apply { + expect("MAIL FROM:<user@localhost>") + output("250 OK") + expect("RCPT TO:<user2@localhost>") + output("250 OK") + expect("DATA") + output("354 End data with <CR><LF>.<CR><LF>") + expect("[message data]") + expect(".") + output("421 4.7.0 Temporary system problem") + expect("QUIT") + output("221 BYE") + closeConnection() + } + val transport = startServerAndCreateSmtpTransport(server) + + assertThat { + transport.sendMessage(message) + }.isFailure() + .isInstanceOf(NegativeSmtpReplyException::class).all { + prop(NegativeSmtpReplyException::replyCode).isEqualTo(421) + prop(NegativeSmtpReplyException::replyText).isEqualTo("4.7.0 Temporary system problem") + } + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `sendMessage() with pipelining`() { + val message = createDefaultMessage() + val server = createServerAndSetupForPlainAuthentication("PIPELINING").apply { + expect("MAIL FROM:<user@localhost>") + expect("RCPT TO:<user2@localhost>") + output("250 OK") + output("250 OK") + expect("DATA") + output("354 End data with <CR><LF>.<CR><LF>") + expect("[message data]") + expect(".") + output("250 OK: queued as 12345") + expect("QUIT") + output("221 BYE") + closeConnection() + } + val transport = startServerAndCreateSmtpTransport(server) + + transport.sendMessage(message) + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `sendMessage() without pipelining`() { + val message = createDefaultMessage() + val server = createServerAndSetupForPlainAuthentication().apply { + expect("MAIL FROM:<user@localhost>") + output("250 OK") + expect("RCPT TO:<user2@localhost>") + output("250 OK") + expect("DATA") + output("354 End data with <CR><LF>.<CR><LF>") + expect("[message data]") + expect(".") + output("250 OK: queued as 12345") + expect("QUIT") + output("221 BYE") + closeConnection() + } + val transport = startServerAndCreateSmtpTransport(server) + + transport.sendMessage(message) + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `sendMessage() with pipelining and negative reply`() { + val message = createDefaultMessage() + val server = createServerAndSetupForPlainAuthentication("PIPELINING").apply { + expect("MAIL FROM:<user@localhost>") + expect("RCPT TO:<user2@localhost>") + output("250 OK") + output("550 remote mail to <user2@localhost> not allowed") + expect("QUIT") + output("221 BYE") + closeConnection() + } + val transport = startServerAndCreateSmtpTransport(server) + + assertThat { + transport.sendMessage(message) + }.isFailure() + .isInstanceOf(NegativeSmtpReplyException::class).all { + prop(NegativeSmtpReplyException::replyCode).isEqualTo(550) + prop(NegativeSmtpReplyException::replyText) + .isEqualTo("remote mail to <user2@localhost> not allowed") + } + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `sendMessage() with pipelining and missing 354 reply for DATA should throw`() { + val message = createDefaultMessage() + val server = createServerAndSetupForPlainAuthentication("PIPELINING") + server.expect("MAIL FROM:<user@localhost>") + server.expect("RCPT TO:<user2@localhost>") + server.output("250 OK") + server.output("550 remote mail to <user2@localhost> not allowed") + server.expect("QUIT") + server.output("221 BYE") + server.closeConnection() + val transport = startServerAndCreateSmtpTransport(server) + + assertThat { + transport.sendMessage(message) + }.isFailure() + .isInstanceOf(NegativeSmtpReplyException::class).all { + prop(NegativeSmtpReplyException::replyCode).isEqualTo(550) + prop(NegativeSmtpReplyException::replyText) + .isEqualTo("remote mail to <user2@localhost> not allowed") + } + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `sendMessage() with pipelining and two 550 replies for recipients should include first error in exception`() { + val message = createMessageWithTwoRecipients() + val server = createServerAndSetupForPlainAuthentication("PIPELINING").apply { + expect("MAIL FROM:<user@localhost>") + expect("RCPT TO:<user2@localhost>") + expect("RCPT TO:<user3@localhost>") + output("250 OK") + output("550 remote mail to <user2@localhost> not allowed") + output("550 remote mail to <user3@localhost> not allowed") + expect("QUIT") + output("221 BYE") + closeConnection() + } + val transport = startServerAndCreateSmtpTransport(server) + + assertThat { + transport.sendMessage(message) + }.isFailure() + .isInstanceOf(NegativeSmtpReplyException::class).all { + prop(NegativeSmtpReplyException::replyCode).isEqualTo(550) + prop(NegativeSmtpReplyException::replyText) + .isEqualTo("remote mail to <user2@localhost> not allowed") + } + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + @Test + fun `sendMessage() with pipelining and both 250 and 550 response for recipients should throw`() { + val message = createMessageWithTwoRecipients() + val server = createServerAndSetupForPlainAuthentication("PIPELINING").apply { + expect("MAIL FROM:<user@localhost>") + expect("RCPT TO:<user2@localhost>") + expect("RCPT TO:<user3@localhost>") + output("250 OK") + output("250 OK") + output("550 remote mail to <user3@localhost> not allowed") + expect("QUIT") + output("221 BYE") + closeConnection() + } + val transport = startServerAndCreateSmtpTransport(server) + + assertThat { + transport.sendMessage(message) + }.isFailure() + .isInstanceOf(NegativeSmtpReplyException::class).all { + prop(NegativeSmtpReplyException::replyCode).isEqualTo(550) + prop(NegativeSmtpReplyException::replyText) + .isEqualTo("remote mail to <user3@localhost> not allowed") + } + + server.verifyConnectionClosed() + server.verifyInteractionCompleted() + } + + private fun startServerAndCreateSmtpTransportWithoutPassword(server: MockSmtpServer): SmtpTransport { + return startServerAndCreateSmtpTransport(server, AuthType.PLAIN, ConnectionSecurity.NONE, null) + } + + private fun startServerAndCreateSmtpTransport( + server: MockSmtpServer, + authenticationType: AuthType = AuthType.PLAIN, + connectionSecurity: ConnectionSecurity = ConnectionSecurity.NONE, + password: String? = PASSWORD, + ): SmtpTransport { + server.start() + val host = server.host + val port = server.port + val serverSettings = ServerSettings( + "smtp", + host, + port, + connectionSecurity, + authenticationType, + USERNAME, + password, + CLIENT_CERTIFICATE_ALIAS, + ) + + return SmtpTransport(serverSettings, socketFactory, oAuth2TokenProvider) + } + + private fun createDefaultMessageBuilder(): TestMessageBuilder { + return TestMessageBuilder() + .from("user@localhost") + .to("user2@localhost") + } + + private fun createDefaultMessage(): Message { + return createDefaultMessageBuilder().build() + } + + private fun createMessageWithTwoRecipients(): Message { + return TestMessageBuilder() + .from("user@localhost") + .to("user2@localhost", "user3@localhost") + .build() + } + + private fun createServerAndSetupForPlainAuthentication(vararg extensions: String): MockSmtpServer { + return MockSmtpServer().apply { + output("220 localhost Simple Mail Transfer Service Ready") + expect("EHLO [127.0.0.1]") + output("250-localhost Hello client.localhost") + + for (extension in extensions) { + output("250-$extension") + } + + output("250 AUTH LOGIN PLAIN CRAM-MD5") + expect("AUTH PLAIN AHVzZXIAcGFzc3dvcmQ=") + output("235 2.7.0 Authentication successful") + } + } + + private fun createMockOAuth2TokenProvider(): OAuth2TokenProvider { + return mock { + on { getToken(anyLong()) } doReturn "oldToken" doReturn "newToken" + } + } +} diff --git a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/TestSmtpLogger.kt b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/TestSmtpLogger.kt new file mode 100644 index 0000000..ca3e263 --- /dev/null +++ b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/TestSmtpLogger.kt @@ -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 +) |
