summaryrefslogtreecommitdiff
path: root/mail/protocols/smtp/src/test/java
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /mail/protocols/smtp/src/test/java
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'mail/protocols/smtp/src/test/java')
-rw-r--r--mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/mockServer/MockSmtpServer.java425
-rw-r--r--mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseParserTest.kt727
-rw-r--r--mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpResponseTest.kt173
-rw-r--r--mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpTransportTest.kt990
-rw-r--r--mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/TestSmtpLogger.kt15
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
+)