From 3cf141553e88352e0bfc7d28465c477bfc637cdc Mon Sep 17 00:00:00 2001 From: Philip Whitehouse Date: Tue, 5 Sep 2017 01:27:22 +0100 Subject: [PATCH] Major reorganisation of the Pop3Store to match the other stores --- .../k9/mail/store/pop3/Pop3Capabilities.java | 22 + .../fsck/k9/mail/store/pop3/Pop3Commands.java | 26 + .../k9/mail/store/pop3/Pop3Connection.java | 437 +++++++ .../k9/mail/store/pop3/Pop3ErrorResponse.java | 16 + .../fsck/k9/mail/store/pop3/Pop3Folder.java | 610 +++++++++ .../fsck/k9/mail/store/pop3/Pop3Message.java | 40 + .../store/pop3/Pop3ResponseInputStream.java | 36 + .../fsck/k9/mail/store/pop3/Pop3Settings.java | 22 + .../fsck/k9/mail/store/pop3/Pop3Store.java | 1096 +---------------- .../k9/mail/store/pop3/MockPop3Server.java | 423 +++++++ .../mail/store/pop3/Pop3CapabilitiesTest.java | 19 + .../mail/store/pop3/Pop3ConnectionTest.java | 490 ++++++++ .../k9/mail/store/pop3/Pop3FolderTest.java | 329 +++++ .../k9/mail/store/pop3/Pop3MessageTest.java | 21 + .../k9/mail/store/pop3/Pop3StoreTest.java | 214 ++-- .../mail/store/pop3/SimplePop3Settings.java | 72 ++ 16 files changed, 2755 insertions(+), 1118 deletions(-) create mode 100644 k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Capabilities.java create mode 100644 k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Commands.java create mode 100644 k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Connection.java create mode 100644 k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3ErrorResponse.java create mode 100644 k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Folder.java create mode 100644 k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Message.java create mode 100644 k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3ResponseInputStream.java create mode 100644 k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Settings.java create mode 100644 k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/MockPop3Server.java create mode 100644 k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3CapabilitiesTest.java create mode 100644 k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.java create mode 100644 k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.java create mode 100644 k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3MessageTest.java create mode 100644 k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/SimplePop3Settings.java diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Capabilities.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Capabilities.java new file mode 100644 index 000000000..87a20f81f --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Capabilities.java @@ -0,0 +1,22 @@ +package com.fsck.k9.mail.store.pop3; + + +class Pop3Capabilities { + boolean cramMD5; + boolean authPlain; + boolean stls; + boolean top; + boolean uidl; + boolean external; + + @Override + public String toString() { + return String.format("CRAM-MD5 %b, PLAIN %b, STLS %b, TOP %b, UIDL %b, EXTERNAL %b", + cramMD5, + authPlain, + stls, + top, + uidl, + external); + } +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Commands.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Commands.java new file mode 100644 index 000000000..09790f799 --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Commands.java @@ -0,0 +1,26 @@ +package com.fsck.k9.mail.store.pop3; + + +class Pop3Commands { + + static final String STLS_COMMAND = "STLS"; + static final String USER_COMMAND = "USER"; + static final String PASS_COMMAND = "PASS"; + static final String CAPA_COMMAND = "CAPA"; + static final String AUTH_COMMAND = "AUTH"; + static final String STAT_COMMAND = "STAT"; + static final String LIST_COMMAND = "LIST"; + static final String UIDL_COMMAND = "UIDL"; + static final String TOP_COMMAND = "TOP"; + static final String RETR_COMMAND = "RETR"; + static final String DELE_COMMAND = "DELE"; + static final String QUIT_COMMAND = "QUIT"; + + static final String STLS_CAPABILITY = "STLS"; + static final String UIDL_CAPABILITY = "UIDL"; + static final String TOP_CAPABILITY = "TOP"; + static final String SASL_CAPABILITY = "SASL"; + static final String AUTH_PLAIN_CAPABILITY = "PLAIN"; + static final String AUTH_CRAM_MD5_CAPABILITY = "CRAM-MD5"; + static final String AUTH_EXTERNAL_CAPABILITY = "EXTERNAL"; +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Connection.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Connection.java new file mode 100644 index 000000000..62a96711c --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Connection.java @@ -0,0 +1,437 @@ +package com.fsck.k9.mail.store.pop3; + + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.security.GeneralSecurityException; +import java.security.KeyManagementException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import com.fsck.k9.mail.AuthType; +import com.fsck.k9.mail.Authentication; +import com.fsck.k9.mail.AuthenticationFailedException; +import com.fsck.k9.mail.CertificateValidationException; +import com.fsck.k9.mail.ConnectionSecurity; +import com.fsck.k9.mail.K9MailLib; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.filter.Base64; +import com.fsck.k9.mail.filter.Hex; +import com.fsck.k9.mail.ssl.TrustedSocketFactory; +import com.fsck.k9.mail.store.RemoteStore; +import javax.net.ssl.SSLException; +import timber.log.Timber; + +import static com.fsck.k9.mail.CertificateValidationException.Reason.MissingCapability; +import static com.fsck.k9.mail.K9MailLib.DEBUG_PROTOCOL_POP3; +import static com.fsck.k9.mail.store.pop3.Pop3Commands.*; + + +class Pop3Connection { + + private final Pop3Settings settings; + private Socket socket; + private BufferedInputStream in; + private BufferedOutputStream out; + private Pop3Capabilities capabilities; + + /** + * This value is {@code true} if the server supports the CAPA command but doesn't advertise + * support for the TOP command OR if the server doesn't support the CAPA command and we + * already unsuccessfully tried to use the TOP command. + */ + private boolean topNotAdvertised; + + Pop3Connection(Pop3Settings settings, + TrustedSocketFactory trustedSocketFactory) throws MessagingException { + try { + this.settings = settings; + SocketAddress socketAddress = new InetSocketAddress(settings.getHost(), settings.getPort()); + if (settings.getConnectionSecurity() == ConnectionSecurity.SSL_TLS_REQUIRED) { + socket = trustedSocketFactory.createSocket(null, settings.getHost(), settings.getPort(), settings.getClientCertificateAlias()); + } else { + socket = new Socket(); + } + + socket.connect(socketAddress, RemoteStore.SOCKET_CONNECT_TIMEOUT); + in = new BufferedInputStream(socket.getInputStream(), 1024); + out = new BufferedOutputStream(socket.getOutputStream(), 512); + + socket.setSoTimeout(RemoteStore.SOCKET_READ_TIMEOUT); + + if (!isOpen()) { + throw new MessagingException("Unable to connect socket"); + } + + String serverGreeting = executeSimpleCommand(null); + + capabilities = getCapabilities(); + + if (settings.getConnectionSecurity() == ConnectionSecurity.STARTTLS_REQUIRED) { + performStartTlsUpgrade(trustedSocketFactory, settings.getHost(), settings.getPort(), settings.getClientCertificateAlias()); + } + + performAuthentication(settings.getAuthType(), serverGreeting); + } catch (SSLException e) { + if (e.getCause() instanceof CertificateException) { + throw new CertificateValidationException(e.getMessage(), e); + } else { + throw new MessagingException("Unable to connect", e); + } + } catch (GeneralSecurityException gse) { + throw new MessagingException( + "Unable to open connection to POP server due to security error.", gse); + } catch (IOException ioe) { + throw new MessagingException("Unable to open connection to POP server.", ioe); + } + } + + /* + * If STARTTLS is not available throws a CertificateValidationException which in K-9 + * triggers a "Certificate error" notification that takes the user to the incoming + * server settings for review. This might be needed if the account was configured with an obsolete + * "STARTTLS (if available)" setting. + */ + private void performStartTlsUpgrade(TrustedSocketFactory trustedSocketFactory, + String host, int port, String clientCertificateAlias) + throws MessagingException, NoSuchAlgorithmException, KeyManagementException, IOException { + if (capabilities.stls) { + executeSimpleCommand(STLS_COMMAND); + + socket = trustedSocketFactory.createSocket( + socket, + host, + port, + clientCertificateAlias); + socket.setSoTimeout(RemoteStore.SOCKET_READ_TIMEOUT); + in = new BufferedInputStream(socket.getInputStream(), 1024); + out = new BufferedOutputStream(socket.getOutputStream(), 512); + if (!isOpen()) { + throw new MessagingException("Unable to connect socket"); + } + capabilities = getCapabilities(); + } else { + throw new CertificateValidationException( + "STARTTLS connection security not available"); + } + + } + + private void performAuthentication(AuthType authType, String serverGreeting) + throws MessagingException { + switch (authType) { + case PLAIN: + if (capabilities.authPlain) { + authPlain(); + } else { + login(); + } + break; + + case CRAM_MD5: + if (capabilities.cramMD5) { + authCramMD5(); + } else { + authAPOP(serverGreeting); + } + break; + + case EXTERNAL: + if (capabilities.external) { + authExternal(); + } else { + // Provide notification to user of a problem authenticating using client certificates + throw new CertificateValidationException(MissingCapability); + } + break; + + default: + throw new MessagingException( + "Unhandled authentication method found in the server settings (bug)."); + } + + } + + boolean isOpen() { + return (in != null && out != null && socket != null + && socket.isConnected() && !socket.isClosed()); + } + + private Pop3Capabilities getCapabilities() throws IOException { + Pop3Capabilities capabilities = new Pop3Capabilities(); + try { + /* + * Try sending an AUTH command with no arguments. + * + * The server may respond with a list of supported SASL + * authentication mechanisms. + * + * Ref.: http://tools.ietf.org/html/draft-myers-sasl-pop3-05 + * + * While this never became a standard, there are servers that + * support it, and Thunderbird includes this check. + */ + executeSimpleCommand(AUTH_COMMAND); + String response; + while ((response = readLine()) != null) { + if (response.equals(".")) { + break; + } + response = response.toUpperCase(Locale.US); + switch (response) { + case AUTH_PLAIN_CAPABILITY: + capabilities.authPlain = true; + break; + case AUTH_CRAM_MD5_CAPABILITY: + capabilities.cramMD5 = true; + break; + case AUTH_EXTERNAL_CAPABILITY: + capabilities.external = true; + break; + } + } + } catch (MessagingException ignored) { + // Assume AUTH command with no arguments is not supported. + } + try { + executeSimpleCommand(CAPA_COMMAND); + String response; + while ((response = readLine()) != null) { + if (response.equals(".")) { + break; + } + response = response.toUpperCase(Locale.US); + if (response.equals(STLS_CAPABILITY)) { + capabilities.stls = true; + } else if (response.equals(UIDL_CAPABILITY)) { + capabilities.uidl = true; + } else if (response.equals(TOP_CAPABILITY)) { + capabilities.top = true; + } else if (response.startsWith(SASL_CAPABILITY)) { + List saslAuthMechanisms = Arrays.asList(response.split(" ")); + if (saslAuthMechanisms.contains(AUTH_PLAIN_CAPABILITY)) { + capabilities.authPlain = true; + } + if (saslAuthMechanisms.contains(AUTH_CRAM_MD5_CAPABILITY)) { + capabilities.cramMD5 = true; + } + } + } + + if (!capabilities.top) { + /* + * If the CAPA command is supported but it doesn't advertise support for the + * TOP command, we won't check for it manually. + */ + topNotAdvertised = true; + } + } catch (MessagingException me) { + /* + * The server may not support the CAPA command, so we just eat this Exception + * and allow the empty capabilities object to be returned. + */ + } + return capabilities; + } + + private void login() throws MessagingException { + executeSimpleCommand(USER_COMMAND + " " + settings.getUsername()); + try { + executeSimpleCommand(PASS_COMMAND + " " + settings.getPassword(), true); + } catch (Pop3ErrorResponse e) { + throw new AuthenticationFailedException( + "POP3 login authentication failed: " + e.getMessage(), e); + } + } + + private void authPlain() throws MessagingException { + executeSimpleCommand("AUTH PLAIN"); + try { + byte[] encodedBytes = Base64.encodeBase64(("\000" + settings.getUsername() + + "\000" + settings.getPassword()).getBytes()); + executeSimpleCommand(new String(encodedBytes), true); + } catch (Pop3ErrorResponse e) { + throw new AuthenticationFailedException( + "POP3 SASL auth PLAIN authentication failed: " + + e.getMessage(), e); + } + } + + private void authAPOP(String serverGreeting) throws MessagingException { + // regex based on RFC 2449 (3.) "Greeting" + String timestamp = serverGreeting.replaceFirst( + "^\\+OK *(?:\\[[^\\]]+\\])?[^<]*(<[^>]*>)?[^<]*$", "$1"); + if ("".equals(timestamp)) { + throw new MessagingException( + "APOP authentication is not supported"); + } + MessageDigest md; + try { + md = MessageDigest.getInstance("MD5"); + } catch (NoSuchAlgorithmException e) { + throw new MessagingException( + "MD5 failure during POP3 auth APOP", e); + } + byte[] digest = md.digest((timestamp + settings.getPassword()).getBytes()); + String hexDigest = Hex.encodeHex(digest); + try { + executeSimpleCommand("APOP " + settings.getUsername() + " " + hexDigest, true); + } catch (Pop3ErrorResponse e) { + throw new AuthenticationFailedException( + "POP3 APOP authentication failed: " + e.getMessage(), e); + } + } + + private void authCramMD5() throws MessagingException { + String b64Nonce = executeSimpleCommand("AUTH CRAM-MD5").replace("+ ", ""); + + String b64CRAM = Authentication.computeCramMd5(settings.getUsername(), settings.getPassword(), b64Nonce); + try { + executeSimpleCommand(b64CRAM, true); + } catch (Pop3ErrorResponse e) { + throw new AuthenticationFailedException( + "POP3 CRAM-MD5 authentication failed: " + + e.getMessage(), e); + } + } + + private void authExternal() throws MessagingException { + try { + executeSimpleCommand( + String.format("AUTH EXTERNAL %s", + Base64.encode(settings.getUsername())), false); + } catch (Pop3ErrorResponse e) { + /* + * Provide notification to the user of a problem authenticating + * using client certificates. We don't use an + * AuthenticationFailedException because that would trigger a + * "Username or password incorrect" notification in + * AccountSetupCheckSettings. + */ + throw new CertificateValidationException( + "POP3 client certificate authentication failed: " + e.getMessage(), e); + } + } + + private void writeLine(String s) throws IOException { + out.write(s.getBytes()); + out.write('\r'); + out.write('\n'); + out.flush(); + } + + String executeSimpleCommand(String command) throws MessagingException { + return executeSimpleCommand(command, false); + } + + private String executeSimpleCommand(String command, boolean sensitive) throws MessagingException { + try { + if (command != null) { + if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) { + if (sensitive && !K9MailLib.isDebugSensitive()) { + Timber.d(">>> [Command Hidden, Enable Sensitive Debug Logging To Show]"); + } else { + Timber.d(">>> %s", command); + } + } + + writeLine(command); + } + + String response = readLine(); + if (response.length() == 0 || response.charAt(0) != '+') { + throw new Pop3ErrorResponse(response); + } + + return response; + } catch (MessagingException me) { + throw me; + } catch (Exception e) { + close(); + throw new MessagingException("Unable to execute POP3 command", e); + } + } + + String readLine() throws IOException { + StringBuilder sb = new StringBuilder(); + int d = in.read(); + if (d == -1) { + throw new IOException("End of stream reached while trying to read line."); + } + do { + if (((char)d) == '\r') { + //noinspection UnnecessaryContinue Makes it easier to follow + continue; + } else if (((char)d) == '\n') { + break; + } else { + sb.append((char)d); + } + } while ((d = in.read()) != -1); + String ret = sb.toString(); + if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) { + Timber.d("<<< %s", ret); + } + return ret; + } + + void close() { + try { + in.close(); + } catch (Exception e) { + /* + * May fail if the connection is already closed. + */ + } + try { + out.close(); + } catch (Exception e) { + /* + * May fail if the connection is already closed. + */ + } + try { + socket.close(); + } catch (Exception e) { + /* + * May fail if the connection is already closed. + */ + } + in = null; + out = null; + socket = null; + } + + boolean supportsTop() { + return capabilities.top; + } + + boolean isTopNotAdvertised() { + return topNotAdvertised; + } + + void setSupportsTop(boolean supportsTop) { + this.capabilities.top = supportsTop; + } + + void setTopNotAdvertised(boolean topNotAdvertised) { + this.topNotAdvertised = topNotAdvertised; + } + + boolean supportsUidl() { + return this.capabilities.uidl; + } + + InputStream getInputStream() { + return in; + } +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3ErrorResponse.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3ErrorResponse.java new file mode 100644 index 000000000..fa3d652aa --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3ErrorResponse.java @@ -0,0 +1,16 @@ +package com.fsck.k9.mail.store.pop3; + + +import com.fsck.k9.mail.MessagingException; + + +/** + * Exception that is thrown if the server returns an error response. + */ +class Pop3ErrorResponse extends MessagingException { + private static final long serialVersionUID = 3672087845857867174L; + + public Pop3ErrorResponse(String message) { + super(message, true); + } +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Folder.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Folder.java new file mode 100644 index 000000000..b4f1781d9 --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Folder.java @@ -0,0 +1,610 @@ +package com.fsck.k9.mail.store.pop3; + + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import android.annotation.SuppressLint; + +import com.fsck.k9.mail.FetchProfile; +import com.fsck.k9.mail.Flag; +import com.fsck.k9.mail.Folder; +import com.fsck.k9.mail.K9MailLib; +import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.MessageRetrievalListener; +import com.fsck.k9.mail.MessagingException; +import timber.log.Timber; + +import static com.fsck.k9.mail.K9MailLib.DEBUG_PROTOCOL_POP3; +import static com.fsck.k9.mail.store.pop3.Pop3Commands.*; + + +/** + * POP3 only supports one folder, "Inbox". So the folder name is the ID here. + */ +class Pop3Folder extends Folder { + private Pop3Store pop3Store; + private Map uidToMsgMap = new HashMap<>(); + @SuppressLint("UseSparseArrays") + private Map msgNumToMsgMap = new HashMap<>(); + private Map uidToMsgNumMap = new HashMap<>(); + private String name; + private int messageCount; + private Pop3Connection connection; + + Pop3Folder(Pop3Store pop3Store, String name) { + super(); + this.pop3Store = pop3Store; + this.name = name; + + if (this.name.equalsIgnoreCase(pop3Store.getConfig().getInboxFolderName())) { + this.name = pop3Store.getConfig().getInboxFolderName(); + } + } + + @Override + public synchronized void open(int mode) throws MessagingException { + if (isOpen()) { + return; + } + + if (!name.equalsIgnoreCase(pop3Store.getConfig().getInboxFolderName())) { + throw new MessagingException("Folder does not exist"); + } + + connection = pop3Store.createConnection(); + + String response = connection.executeSimpleCommand(STAT_COMMAND); + String[] parts = response.split(" "); + messageCount = Integer.parseInt(parts[1]); + + uidToMsgMap.clear(); + msgNumToMsgMap.clear(); + uidToMsgNumMap.clear(); + } + + @Override + public boolean isOpen() { + return connection != null && connection.isOpen(); + } + + @Override + public int getMode() { + return Folder.OPEN_MODE_RW; + } + + @Override + public void close() { + try { + if (isOpen()) { + connection.executeSimpleCommand(QUIT_COMMAND); + } + } catch (Exception e) { + /* + * QUIT may fail if the connection is already closed. We don't care. It's just + * being friendly. + */ + } + + if (connection != null) { + connection.close(); + connection = null; + } + } + + @Override + public String getName() { + return name; + } + + @Override + public boolean create(FolderType type) throws MessagingException { + return false; + } + + @Override + public boolean exists() throws MessagingException { + return name.equalsIgnoreCase(pop3Store.getConfig().getInboxFolderName()); + } + + @Override + public int getMessageCount() { + return messageCount; + } + + @Override + public int getUnreadMessageCount() throws MessagingException { + return -1; + } + @Override + public int getFlaggedMessageCount() throws MessagingException { + return -1; + } + + @Override + public Pop3Message getMessage(String uid) throws MessagingException { + Pop3Message message = uidToMsgMap.get(uid); + if (message == null) { + message = new Pop3Message(uid, this); + } + return message; + } + + @Override + public List getMessages(int start, int end, Date earliestDate, MessageRetrievalListener listener) + throws MessagingException { + if (start < 1 || end < 1 || end < start) { + throw new MessagingException(String.format(Locale.US, "Invalid message set %d %d", + start, end)); + } + try { + indexMsgNums(start, end); + } catch (IOException ioe) { + throw new MessagingException("getMessages", ioe); + } + List messages = new ArrayList<>(); + int i = 0; + for (int msgNum = start; msgNum <= end; msgNum++) { + Pop3Message message = msgNumToMsgMap.get(msgNum); + if (message == null) { + /* + * There could be gaps in the message numbers or malformed + * responses which lead to "gaps" in msgNumToMsgMap. + * + * See issue 2252 + */ + continue; + } + + if (listener != null) { + listener.messageStarted(message.getUid(), i++, (end - start) + 1); + } + messages.add(message); + if (listener != null) { + listener.messageFinished(message, i++, (end - start) + 1); + } + } + return messages; + } + + @Override + public boolean areMoreMessagesAvailable(int indexOfOldestMessage, Date earliestDate) { + return indexOfOldestMessage > 1; + } + + /** + * Ensures that the given message set (from start to end inclusive) + * has been queried so that uids are available in the local cache. + */ + private void indexMsgNums(int start, int end) throws MessagingException, IOException { + int unindexedMessageCount = 0; + for (int msgNum = start; msgNum <= end; msgNum++) { + if (msgNumToMsgMap.get(msgNum) == null) { + unindexedMessageCount++; + } + } + if (unindexedMessageCount == 0) { + return; + } + if (unindexedMessageCount < 50 && messageCount > 5000) { + /* + * In extreme cases we'll do a UIDL command per message instead of a bulk + * download. + */ + for (int msgNum = start; msgNum <= end; msgNum++) { + Pop3Message message = msgNumToMsgMap.get(msgNum); + if (message == null) { + String response = connection.executeSimpleCommand(UIDL_COMMAND + " " + msgNum); + // response = "+OK msgNum msgUid" + String[] uidParts = response.split(" +"); + if (uidParts.length < 3 || !"+OK".equals(uidParts[0])) { + Timber.e("ERR response: %s", response); + return; + } + String msgUid = uidParts[2]; + message = new Pop3Message(msgUid, this); + indexMessage(msgNum, message); + } + } + } else { + connection.executeSimpleCommand(UIDL_COMMAND); + String response; + while ((response = connection.readLine()) != null) { + if (response.equals(".")) { + break; + } + + /* + * Yet another work-around for buggy server software: + * split the response into message number and unique identifier, no matter how many spaces it has + * + * Example for a malformed response: + * 1 2011071307115510400ae3e9e00bmu9 + * + * Note the three spaces between message number and unique identifier. + * See issue 3546 + */ + + String[] uidParts = response.split(" +"); + if ((uidParts.length >= 3) && "+OK".equals(uidParts[0])) { + /* + * At least one server software places a "+OK" in + * front of every line in the unique-id listing. + * + * Fix up the array if we detected this behavior. + * See Issue 1237 + */ + uidParts[0] = uidParts[1]; + uidParts[1] = uidParts[2]; + } + if (uidParts.length >= 2) { + Integer msgNum = Integer.valueOf(uidParts[0]); + String msgUid = uidParts[1]; + if (msgNum >= start && msgNum <= end) { + Pop3Message message = msgNumToMsgMap.get(msgNum); + if (message == null) { + message = new Pop3Message(msgUid, this); + indexMessage(msgNum, message); + } + } + } + } + } + } + + private void indexUids(List uids) + throws MessagingException, IOException { + Set unindexedUids = new HashSet<>(); + for (String uid : uids) { + if (uidToMsgMap.get(uid) == null) { + if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) { + Timber.d("Need to index UID %s", uid); + } + unindexedUids.add(uid); + } + } + if (unindexedUids.isEmpty()) { + return; + } + /* + * If we are missing uids in the cache the only sure way to + * get them is to do a full UIDL list. A possible optimization + * would be trying UIDL for the latest X messages and praying. + */ + connection.executeSimpleCommand(UIDL_COMMAND); + String response; + while ((response = connection.readLine()) != null) { + if (response.equals(".")) { + break; + } + String[] uidParts = response.split(" +"); + + // Ignore messages without a unique-id + if (uidParts.length >= 2) { + Integer msgNum = Integer.valueOf(uidParts[0]); + String msgUid = uidParts[1]; + if (unindexedUids.contains(msgUid)) { + if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) { + Timber.d("Got msgNum %d for UID %s", msgNum, msgUid); + } + + Pop3Message message = uidToMsgMap.get(msgUid); + if (message == null) { + message = new Pop3Message(msgUid, this); + } + indexMessage(msgNum, message); + } + } + } + } + + private void indexMessage(int msgNum, Pop3Message message) { + if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) { + Timber.d("Adding index for UID %s to msgNum %d", message.getUid(), msgNum); + } + msgNumToMsgMap.put(msgNum, message); + uidToMsgMap.put(message.getUid(), message); + uidToMsgNumMap.put(message.getUid(), msgNum); + } + + /** + * Fetch the items contained in the FetchProfile into the given set of + * Messages in as efficient a manner as possible. + * @param messages Messages to populate + * @param fp The contents to populate + */ + @Override + public void fetch(List messages, FetchProfile fp, + MessageRetrievalListener listener) + throws MessagingException { + if (messages == null || messages.isEmpty()) { + return; + } + List uids = new ArrayList<>(); + for (Message message : messages) { + uids.add(message.getUid()); + } + try { + indexUids(uids); + } catch (IOException ioe) { + throw new MessagingException("fetch", ioe); + } + try { + if (fp.contains(FetchProfile.Item.ENVELOPE)) { + /* + * We pass the listener only if there are other things to do in the + * FetchProfile. Since fetchEnvelop works in bulk and eveything else + * works one at a time if we let fetchEnvelope send events the + * event would get sent twice. + */ + fetchEnvelope(messages, fp.size() == 1 ? listener : null); + } + } catch (IOException ioe) { + throw new MessagingException("fetch", ioe); + } + for (int i = 0, count = messages.size(); i < count; i++) { + Pop3Message pop3Message = messages.get(i); + try { + if (listener != null && !fp.contains(FetchProfile.Item.ENVELOPE)) { + listener.messageStarted(pop3Message.getUid(), i, count); + } + if (fp.contains(FetchProfile.Item.BODY)) { + fetchBody(pop3Message, -1); + } else if (fp.contains(FetchProfile.Item.BODY_SANE)) { + /* + * To convert the suggested download size we take the size + * divided by the maximum line size (76). + */ + if (pop3Store.getConfig().getMaximumAutoDownloadMessageSize() > 0) { + fetchBody(pop3Message, + (pop3Store.getConfig().getMaximumAutoDownloadMessageSize() / 76)); + } else { + fetchBody(pop3Message, -1); + } + } else if (fp.contains(FetchProfile.Item.STRUCTURE)) { + /* + * If the user is requesting STRUCTURE we are required to set the body + * to null since we do not support the function. + */ + pop3Message.setBody(null); + } + if (listener != null && !(fp.contains(FetchProfile.Item.ENVELOPE) && fp.size() == 1)) { + listener.messageFinished(pop3Message, i, count); + } + } catch (IOException ioe) { + throw new MessagingException("Unable to fetch message", ioe); + } + } + } + + private void fetchEnvelope(List messages, + MessageRetrievalListener listener) throws IOException, MessagingException { + int unsizedMessages = 0; + for (Message message : messages) { + if (message.getSize() == -1) { + unsizedMessages++; + } + } + if (unsizedMessages == 0) { + return; + } + if (unsizedMessages < 50 && messageCount > 5000) { + /* + * In extreme cases we'll do a command per message instead of a bulk request + * to hopefully save some time and bandwidth. + */ + for (int i = 0, count = messages.size(); i < count; i++) { + Pop3Message message = messages.get(i); + if (listener != null) { + listener.messageStarted(message.getUid(), i, count); + } + String response = connection.executeSimpleCommand( + String.format(Locale.US, LIST_COMMAND + " %d", + uidToMsgNumMap.get(message.getUid()))); + String[] listParts = response.split(" "); + //int msgNum = Integer.parseInt(listParts[1]); + int msgSize = Integer.parseInt(listParts[2]); + message.setSize(msgSize); + if (listener != null) { + listener.messageFinished(message, i, count); + } + } + } else { + Set msgUidIndex = new HashSet<>(); + for (Message message : messages) { + msgUidIndex.add(message.getUid()); + } + int i = 0, count = messages.size(); + connection.executeSimpleCommand(LIST_COMMAND); + String response; + while ((response = connection.readLine()) != null) { + if (response.equals(".")) { + break; + } + String[] listParts = response.split(" "); + int msgNum = Integer.parseInt(listParts[0]); + int msgSize = Integer.parseInt(listParts[1]); + Pop3Message pop3Message = msgNumToMsgMap.get(msgNum); + if (pop3Message != null && msgUidIndex.contains(pop3Message.getUid())) { + if (listener != null) { + listener.messageStarted(pop3Message.getUid(), i, count); + } + pop3Message.setSize(msgSize); + if (listener != null) { + listener.messageFinished(pop3Message, i, count); + } + i++; + } + } + } + } + + /** + * Fetches the body of the given message, limiting the downloaded data to the specified + * number of lines if possible. + * + * If lines is -1 the entire message is fetched. This is implemented with RETR for + * lines = -1 or TOP for any other value. If the server does not support TOP, RETR is used + * instead. + */ + private void fetchBody(Pop3Message message, int lines) + throws IOException, MessagingException { + String response = null; + + // Try hard to use the TOP command if we're not asked to download the whole message. + if (lines != -1 && (!connection.isTopNotAdvertised() || connection.supportsTop())) { + try { + if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3 && !connection.supportsTop()) { + Timber.d("This server doesn't support the CAPA command. " + + "Checking to see if the TOP command is supported nevertheless."); + } + + response = connection.executeSimpleCommand( + String.format(Locale.US, TOP_COMMAND + " %d %d", + uidToMsgNumMap.get(message.getUid()), lines)); + // TOP command is supported. Remember this for the next time. + connection.setSupportsTop(true); + } catch (Pop3ErrorResponse e) { + if (connection.supportsTop()) { + // The TOP command should be supported but something went wrong. + throw e; + } else { + if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) { + Timber.d("The server really doesn't support the TOP " + + "command. Using RETR instead."); + } + + // Don't try to use the TOP command again. + connection.setTopNotAdvertised(false); + } + } + } + + if (response == null) { + connection.executeSimpleCommand(String.format(Locale.US, RETR_COMMAND + " %d", + uidToMsgNumMap.get(message.getUid()))); + } + + try { + message.parse(new Pop3ResponseInputStream(connection.getInputStream())); + + // TODO: if we've received fewer lines than requested we also have the complete message. + if (lines == -1 || !connection.supportsTop()) { + message.setFlag(Flag.X_DOWNLOADED_FULL, true); + } + } catch (MessagingException me) { + /* + * If we're only downloading headers it's possible + * we'll get a broken MIME message which we're not + * real worried about. If we've downloaded the body + * and can't parse it we need to let the user know. + */ + if (lines == -1) { + throw me; + } + } + } + + @Override + public Map appendMessages(List messages) throws MessagingException { + return null; + } + + @Override + public void delete(boolean recurse) throws MessagingException { + } + + @Override + public void delete(List msgs, String trashFolderName) throws MessagingException { + setFlags(msgs, Collections.singleton(Flag.DELETED), true); + } + + @Override + public String getUidFromMessageId(Message message) throws MessagingException { + return null; + } + + @Override + public void setFlags(final Set flags, boolean value) throws MessagingException { + throw new UnsupportedOperationException("POP3: No setFlags(Set,boolean)"); + } + + @Override + public void setFlags(List messages, final Set flags, boolean value) + throws MessagingException { + if (!value || !flags.contains(Flag.DELETED)) { + /* + * The only flagging we support is setting the Deleted flag. + */ + return; + } + List uids = new ArrayList<>(); + try { + for (Message message : messages) { + uids.add(message.getUid()); + } + + indexUids(uids); + } catch (IOException ioe) { + throw new MessagingException("Could not get message number for uid " + uids, ioe); + } + for (Message message : messages) { + + Integer msgNum = uidToMsgNumMap.get(message.getUid()); + if (msgNum == null) { + MessagingException me = new MessagingException("Could not delete message " + message.getUid() + + " because no msgNum found; permanent error"); + me.setPermanentFailure(true); + throw me; + } + open(Folder.OPEN_MODE_RW); + connection.executeSimpleCommand(String.format(DELE_COMMAND + " %s", msgNum)); + } + } + + @Override + public boolean isFlagSupported(Flag flag) { + return (flag == Flag.DELETED); + } + + @Override + public boolean supportsFetchingFlags() { + return false; + } + + @Override + public boolean equals(Object o) { + if (o instanceof Pop3Folder) { + return ((Pop3Folder) o).name.equals(name); + } + return super.equals(o); + } + + @Override + public int hashCode() { + return name.hashCode(); + } + + void requestUidl() throws MessagingException { + if (!connection.supportsUidl()) { + /* + * Run an additional test to see if UIDL is supported on the server. If it's not we + * can't service this account. + */ + + /* + * If the server doesn't support UIDL it will return a - response, which causes + * executeSimpleCommand to throw a MessagingException, exiting this method. + */ + connection.executeSimpleCommand(UIDL_COMMAND); + } + } +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Message.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Message.java new file mode 100644 index 000000000..c6f6f7f6b --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Message.java @@ -0,0 +1,40 @@ +package com.fsck.k9.mail.store.pop3; + + +import java.util.Collections; + +import com.fsck.k9.mail.Flag; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.MimeMessage; + + +class Pop3Message extends MimeMessage { + Pop3Message(String uid, Pop3Folder folder) { + mUid = uid; + mFolder = folder; + mSize = -1; + } + + public void setSize(int size) { + mSize = size; + } + + @Override + public void setFlag(Flag flag, boolean set) throws MessagingException { + super.setFlag(flag, set); + mFolder.setFlags(Collections.singletonList(this), Collections.singleton(flag), set); + } + + @Override + public void delete(String trashFolderName) throws MessagingException { + // try + // { + // Poor POP3 users, we can't copy the message to the Trash folder, but they still want a delete + setFlag(Flag.DELETED, true); + // } +// catch (MessagingException me) +// { +// Log.w(LOG_TAG, "Could not delete non-existent message", me); +// } + } +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3ResponseInputStream.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3ResponseInputStream.java new file mode 100644 index 000000000..4e96a0542 --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3ResponseInputStream.java @@ -0,0 +1,36 @@ +package com.fsck.k9.mail.store.pop3; + + +import java.io.IOException; +import java.io.InputStream; + + +class Pop3ResponseInputStream extends InputStream { + private InputStream mIn; + private boolean mStartOfLine = true; + private boolean mFinished; + + Pop3ResponseInputStream(InputStream in) { + mIn = in; + } + + @Override + public int read() throws IOException { + if (mFinished) { + return -1; + } + int d = mIn.read(); + if (mStartOfLine && d == '.') { + d = mIn.read(); + if (d == '\r') { + mFinished = true; + mIn.read(); + return -1; + } + } + + mStartOfLine = (d == '\n'); + + return d; + } +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Settings.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Settings.java new file mode 100644 index 000000000..3bba4f137 --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Settings.java @@ -0,0 +1,22 @@ +package com.fsck.k9.mail.store.pop3; + + +import com.fsck.k9.mail.AuthType; +import com.fsck.k9.mail.ConnectionSecurity; + + +interface Pop3Settings { + String getHost(); + + int getPort(); + + ConnectionSecurity getConnectionSecurity(); + + AuthType getAuthType(); + + String getUsername(); + + String getPassword(); + + String getClientCertificateAlias(); +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Store.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Store.java index ad5a8f969..2b3dde34b 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Store.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/pop3/Pop3Store.java @@ -1,67 +1,25 @@ - package com.fsck.k9.mail.store.pop3; -import android.annotation.SuppressLint; +import android.support.annotation.NonNull; import com.fsck.k9.mail.*; -import com.fsck.k9.mail.filter.Base64; -import com.fsck.k9.mail.filter.Hex; -import com.fsck.k9.mail.internet.MimeMessage; import com.fsck.k9.mail.ServerSettings.Type; import com.fsck.k9.mail.ssl.TrustedSocketFactory; import com.fsck.k9.mail.store.RemoteStore; import com.fsck.k9.mail.store.StoreConfig; -import javax.net.ssl.SSLException; -import timber.log.Timber; - -import java.io.*; import java.net.*; -import java.security.GeneralSecurityException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Date; import java.util.LinkedList; import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Locale; import java.util.Map; -import java.util.Set; -import static com.fsck.k9.mail.K9MailLib.DEBUG_PROTOCOL_POP3; -import static com.fsck.k9.mail.CertificateValidationException.Reason.MissingCapability; import static com.fsck.k9.mail.helper.UrlEncodingHelper.decodeUtf8; import static com.fsck.k9.mail.helper.UrlEncodingHelper.encodeUtf8; public class Pop3Store extends RemoteStore { - private static final String STLS_COMMAND = "STLS"; - private static final String USER_COMMAND = "USER"; - private static final String PASS_COMMAND = "PASS"; - private static final String CAPA_COMMAND = "CAPA"; - private static final String AUTH_COMMAND = "AUTH"; - private static final String STAT_COMMAND = "STAT"; - private static final String LIST_COMMAND = "LIST"; - private static final String UIDL_COMMAND = "UIDL"; - private static final String TOP_COMMAND = "TOP"; - private static final String RETR_COMMAND = "RETR"; - private static final String DELE_COMMAND = "DELE"; - private static final String QUIT_COMMAND = "QUIT"; - - private static final String STLS_CAPABILITY = "STLS"; - private static final String UIDL_CAPABILITY = "UIDL"; - private static final String TOP_CAPABILITY = "TOP"; - private static final String SASL_CAPABILITY = "SASL"; - private static final String AUTH_PLAIN_CAPABILITY = "PLAIN"; - private static final String AUTH_CRAM_MD5_CAPABILITY = "CRAM-MD5"; - private static final String AUTH_EXTERNAL_CAPABILITY = "EXTERNAL"; - /** * Decodes a Pop3Store URI. * @@ -200,23 +158,15 @@ public class Pop3Store extends RemoteStore { } - private String mHost; - private int mPort; - private String mUsername; - private String mPassword; - private String mClientCertificateAlias; - private AuthType mAuthType; - private ConnectionSecurity mConnectionSecurity; - private Map mFolders = new HashMap(); - private Pop3Capabilities mCapabilities; - - /** - * This value is {@code true} if the server supports the CAPA command but doesn't advertise - * support for the TOP command OR if the server doesn't support the CAPA command and we - * already unsuccessfully tried to use the TOP command. - */ - private boolean mTopNotSupported; + private final String host; + private final int port; + private final String username; + private final String password; + private final String clientCertificateAlias; + private final AuthType authType; + private final ConnectionSecurity connectionSecurity; + private Map mFolders = new HashMap(); public Pop3Store(StoreConfig storeConfig, TrustedSocketFactory socketFactory) throws MessagingException { super(storeConfig, socketFactory); @@ -228,52 +178,39 @@ public class Pop3Store extends RemoteStore { throw new MessagingException("Error while decoding store URI", e); } - mHost = settings.host; - mPort = settings.port; - - mConnectionSecurity = settings.connectionSecurity; - - mUsername = settings.username; - mPassword = settings.password; - mClientCertificateAlias = settings.clientCertificateAlias; - mAuthType = settings.authenticationType; + host = settings.host; + port = settings.port; + connectionSecurity = settings.connectionSecurity; + username = settings.username; + password = settings.password; + clientCertificateAlias = settings.clientCertificateAlias; + authType = settings.authenticationType; } @Override - public Folder getFolder(String name) { - Folder folder = mFolders.get(name); + @NonNull + public Pop3Folder getFolder(String name) { + Pop3Folder folder = mFolders.get(name); if (folder == null) { - folder = new Pop3Folder(name); + folder = new Pop3Folder(this, name); mFolders.put(folder.getName(), folder); } return folder; } @Override - public List getPersonalNamespaces(boolean forceListAll) throws MessagingException { - List folders = new LinkedList(); + public List getPersonalNamespaces(boolean forceListAll) throws MessagingException { + List folders = new LinkedList<>(); folders.add(getFolder(mStoreConfig.getInboxFolderName())); return folders; } @Override public void checkSettings() throws MessagingException { - Pop3Folder folder = new Pop3Folder(mStoreConfig.getInboxFolderName()); + Pop3Folder folder = new Pop3Folder(this, mStoreConfig.getInboxFolderName()); try { folder.open(Folder.OPEN_MODE_RW); - if (!mCapabilities.uidl) { - /* - * Run an additional test to see if UIDL is supported on the server. If it's not we - * can't service this account. - */ - - /* - * If the server doesn't support UIDL it will return a - response, which causes - * executeSimpleCommand to throw a MessagingException, exiting this method. - */ - folder.executeSimpleCommand(UIDL_COMMAND); - - } + folder.requestUidl(); } finally { folder.close(); @@ -285,989 +222,50 @@ public class Pop3Store extends RemoteStore { return false; } - class Pop3Folder extends Folder { - private Socket mSocket; - private InputStream mIn; - private OutputStream mOut; - private Map mUidToMsgMap = new HashMap(); - @SuppressLint("UseSparseArrays") - private Map mMsgNumToMsgMap = new HashMap(); - private Map mUidToMsgNumMap = new HashMap(); - private String mName; - private int mMessageCount; - public Pop3Folder(String name) { - super(); - this.mName = name; + StoreConfig getConfig() { + return mStoreConfig; + } - if (mName.equalsIgnoreCase(mStoreConfig.getInboxFolderName())) { - mName = mStoreConfig.getInboxFolderName(); - } + public Pop3Connection createConnection() throws MessagingException { + return new Pop3Connection(new StorePop3Settings(), mTrustedSocketFactory); + } + + private class StorePop3Settings implements Pop3Settings { + @Override + public String getHost() { + return host; } @Override - public synchronized void open(int mode) throws MessagingException { - if (isOpen()) { - return; - } - - if (!mName.equalsIgnoreCase(mStoreConfig.getInboxFolderName())) { - throw new MessagingException("Folder does not exist"); - } - - try { - SocketAddress socketAddress = new InetSocketAddress(mHost, mPort); - if (mConnectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED) { - mSocket = mTrustedSocketFactory.createSocket(null, mHost, mPort, mClientCertificateAlias); - } else { - mSocket = new Socket(); - } - - mSocket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT); - mIn = new BufferedInputStream(mSocket.getInputStream(), 1024); - mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512); - - mSocket.setSoTimeout(SOCKET_READ_TIMEOUT); - if (!isOpen()) { - throw new MessagingException("Unable to connect socket"); - } - - String serverGreeting = executeSimpleCommand(null); - - mCapabilities = getCapabilities(); - if (mConnectionSecurity == ConnectionSecurity.STARTTLS_REQUIRED) { - - if (mCapabilities.stls) { - executeSimpleCommand(STLS_COMMAND); - - mSocket = mTrustedSocketFactory.createSocket( - mSocket, - mHost, - mPort, - mClientCertificateAlias); - mSocket.setSoTimeout(SOCKET_READ_TIMEOUT); - mIn = new BufferedInputStream(mSocket.getInputStream(), 1024); - mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512); - if (!isOpen()) { - throw new MessagingException("Unable to connect socket"); - } - mCapabilities = getCapabilities(); - } else { - /* - * This exception triggers a "Certificate error" - * notification that takes the user to the incoming - * server settings for review. This might be needed if - * the account was configured with an obsolete - * "STARTTLS (if available)" setting. - */ - throw new CertificateValidationException( - "STARTTLS connection security not available"); - } - } - - switch (mAuthType) { - case PLAIN: - if (mCapabilities.authPlain) { - authPlain(); - } else { - login(); - } - break; - - case CRAM_MD5: - if (mCapabilities.cramMD5) { - authCramMD5(); - } else { - authAPOP(serverGreeting); - } - break; - - case EXTERNAL: - if (mCapabilities.external) { - authExternal(); - } else { - // Provide notification to user of a problem authenticating using client certificates - throw new CertificateValidationException(MissingCapability); - } - break; - - default: - throw new MessagingException( - "Unhandled authentication method found in the server settings (bug)."); - } - } catch (SSLException e) { - if (e.getCause() instanceof CertificateException) { - throw new CertificateValidationException(e.getMessage(), e); - } else { - throw new MessagingException("Unable to connect", e); - } - } catch (GeneralSecurityException gse) { - throw new MessagingException( - "Unable to open connection to POP server due to security error.", gse); - } catch (IOException ioe) { - throw new MessagingException("Unable to open connection to POP server.", ioe); - } - - String response = executeSimpleCommand(STAT_COMMAND); - String[] parts = response.split(" "); - mMessageCount = Integer.parseInt(parts[1]); - - mUidToMsgMap.clear(); - mMsgNumToMsgMap.clear(); - mUidToMsgNumMap.clear(); - } - - private void login() throws MessagingException { - executeSimpleCommand(USER_COMMAND + " " + mUsername); - try { - executeSimpleCommand(PASS_COMMAND + " " + mPassword, true); - } catch (Pop3ErrorResponse e) { - throw new AuthenticationFailedException( - "POP3 login authentication failed: " + e.getMessage(), e); - } - } - - private void authPlain() throws MessagingException { - executeSimpleCommand("AUTH PLAIN"); - try { - byte[] encodedBytes = Base64.encodeBase64(("\000" + mUsername - + "\000" + mPassword).getBytes()); - executeSimpleCommand(new String(encodedBytes), true); - } catch (Pop3ErrorResponse e) { - throw new AuthenticationFailedException( - "POP3 SASL auth PLAIN authentication failed: " - + e.getMessage(), e); - } - } - - private void authAPOP(String serverGreeting) throws MessagingException { - // regex based on RFC 2449 (3.) "Greeting" - String timestamp = serverGreeting.replaceFirst( - "^\\+OK *(?:\\[[^\\]]+\\])?[^<]*(<[^>]*>)?[^<]*$", "$1"); - if ("".equals(timestamp)) { - throw new MessagingException( - "APOP authentication is not supported"); - } - MessageDigest md; - try { - md = MessageDigest.getInstance("MD5"); - } catch (NoSuchAlgorithmException e) { - throw new MessagingException( - "MD5 failure during POP3 auth APOP", e); - } - byte[] digest = md.digest((timestamp + mPassword).getBytes()); - String hexDigest = Hex.encodeHex(digest); - try { - executeSimpleCommand("APOP " + mUsername + " " + hexDigest, true); - } catch (Pop3ErrorResponse e) { - throw new AuthenticationFailedException( - "POP3 APOP authentication failed: " + e.getMessage(), e); - } - } - - private void authCramMD5() throws MessagingException { - String b64Nonce = executeSimpleCommand("AUTH CRAM-MD5").replace("+ ", ""); - - String b64CRAM = Authentication.computeCramMd5(mUsername, mPassword, b64Nonce); - try { - executeSimpleCommand(b64CRAM, true); - } catch (Pop3ErrorResponse e) { - throw new AuthenticationFailedException( - "POP3 CRAM-MD5 authentication failed: " - + e.getMessage(), e); - } - } - - private void authExternal() throws MessagingException { - try { - executeSimpleCommand( - String.format("AUTH EXTERNAL %s", - Base64.encode(mUsername)), false); - } catch (Pop3ErrorResponse e) { - /* - * Provide notification to the user of a problem authenticating - * using client certificates. We don't use an - * AuthenticationFailedException because that would trigger a - * "Username or password incorrect" notification in - * AccountSetupCheckSettings. - */ - throw new CertificateValidationException( - "POP3 client certificate authentication failed: " + e.getMessage(), e); - } + public int getPort() { + return port; } @Override - public boolean isOpen() { - return (mIn != null && mOut != null && mSocket != null - && mSocket.isConnected() && !mSocket.isClosed()); + public ConnectionSecurity getConnectionSecurity() { + return connectionSecurity; } @Override - public int getMode() { - return Folder.OPEN_MODE_RW; + public AuthType getAuthType() { + return authType; } @Override - public void close() { - try { - if (isOpen()) { - executeSimpleCommand(QUIT_COMMAND); - } - } catch (Exception e) { - /* - * QUIT may fail if the connection is already closed. We don't care. It's just - * being friendly. - */ - } - - closeIO(); - } - - private void closeIO() { - try { - mIn.close(); - } catch (Exception e) { - /* - * May fail if the connection is already closed. - */ - } - try { - mOut.close(); - } catch (Exception e) { - /* - * May fail if the connection is already closed. - */ - } - try { - mSocket.close(); - } catch (Exception e) { - /* - * May fail if the connection is already closed. - */ - } - mIn = null; - mOut = null; - mSocket = null; + public String getUsername() { + return username; } @Override - public String getName() { - return mName; + public String getPassword() { + return password; } @Override - public boolean create(FolderType type) throws MessagingException { - return false; - } - - @Override - public boolean exists() throws MessagingException { - return mName.equalsIgnoreCase(mStoreConfig.getInboxFolderName()); - } - - @Override - public int getMessageCount() { - return mMessageCount; - } - - @Override - public int getUnreadMessageCount() throws MessagingException { - return -1; - } - @Override - public int getFlaggedMessageCount() throws MessagingException { - return -1; - } - - @Override - public Pop3Message getMessage(String uid) throws MessagingException { - Pop3Message message = mUidToMsgMap.get(uid); - if (message == null) { - message = new Pop3Message(uid, this); - } - return message; - } - - @Override - public List getMessages(int start, int end, Date earliestDate, MessageRetrievalListener listener) - throws MessagingException { - if (start < 1 || end < 1 || end < start) { - throw new MessagingException(String.format(Locale.US, "Invalid message set %d %d", - start, end)); - } - try { - indexMsgNums(start, end); - } catch (IOException ioe) { - throw new MessagingException("getMessages", ioe); - } - List messages = new ArrayList(); - int i = 0; - for (int msgNum = start; msgNum <= end; msgNum++) { - Pop3Message message = mMsgNumToMsgMap.get(msgNum); - if (message == null) { - /* - * There could be gaps in the message numbers or malformed - * responses which lead to "gaps" in mMsgNumToMsgMap. - * - * See issue 2252 - */ - continue; - } - - if (listener != null) { - listener.messageStarted(message.getUid(), i++, (end - start) + 1); - } - messages.add(message); - if (listener != null) { - listener.messageFinished(message, i++, (end - start) + 1); - } - } - return messages; - } - - @Override - public boolean areMoreMessagesAvailable(int indexOfOldestMessage, Date earliestDate) { - return indexOfOldestMessage > 1; - } - - /** - * Ensures that the given message set (from start to end inclusive) - * has been queried so that uids are available in the local cache. - * @param start - * @param end - * @throws MessagingException - * @throws IOException - */ - private void indexMsgNums(int start, int end) - throws MessagingException, IOException { - int unindexedMessageCount = 0; - for (int msgNum = start; msgNum <= end; msgNum++) { - if (mMsgNumToMsgMap.get(msgNum) == null) { - unindexedMessageCount++; - } - } - if (unindexedMessageCount == 0) { - return; - } - if (unindexedMessageCount < 50 && mMessageCount > 5000) { - /* - * In extreme cases we'll do a UIDL command per message instead of a bulk - * download. - */ - for (int msgNum = start; msgNum <= end; msgNum++) { - Pop3Message message = mMsgNumToMsgMap.get(msgNum); - if (message == null) { - String response = executeSimpleCommand(UIDL_COMMAND + " " + msgNum); - // response = "+OK msgNum msgUid" - String[] uidParts = response.split(" +"); - if (uidParts.length < 3 || !"+OK".equals(uidParts[0])) { - Timber.e("ERR response: %s", response); - return; - } - String msgUid = uidParts[2]; - message = new Pop3Message(msgUid, this); - indexMessage(msgNum, message); - } - } - } else { - String response = executeSimpleCommand(UIDL_COMMAND); - while ((response = readLine()) != null) { - if (response.equals(".")) { - break; - } - - /* - * Yet another work-around for buggy server software: - * split the response into message number and unique identifier, no matter how many spaces it has - * - * Example for a malformed response: - * 1 2011071307115510400ae3e9e00bmu9 - * - * Note the three spaces between message number and unique identifier. - * See issue 3546 - */ - - String[] uidParts = response.split(" +"); - if ((uidParts.length >= 3) && "+OK".equals(uidParts[0])) { - /* - * At least one server software places a "+OK" in - * front of every line in the unique-id listing. - * - * Fix up the array if we detected this behavior. - * See Issue 1237 - */ - uidParts[0] = uidParts[1]; - uidParts[1] = uidParts[2]; - } - if (uidParts.length >= 2) { - Integer msgNum = Integer.valueOf(uidParts[0]); - String msgUid = uidParts[1]; - if (msgNum >= start && msgNum <= end) { - Pop3Message message = mMsgNumToMsgMap.get(msgNum); - if (message == null) { - message = new Pop3Message(msgUid, this); - indexMessage(msgNum, message); - } - } - } - } - } - } - - private void indexUids(List uids) - throws MessagingException, IOException { - Set unindexedUids = new HashSet(); - for (String uid : uids) { - if (mUidToMsgMap.get(uid) == null) { - if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) { - Timber.d("Need to index UID %s", uid); - } - unindexedUids.add(uid); - } - } - if (unindexedUids.isEmpty()) { - return; - } - /* - * If we are missing uids in the cache the only sure way to - * get them is to do a full UIDL list. A possible optimization - * would be trying UIDL for the latest X messages and praying. - */ - String response = executeSimpleCommand(UIDL_COMMAND); - while ((response = readLine()) != null) { - if (response.equals(".")) { - break; - } - String[] uidParts = response.split(" +"); - - // Ignore messages without a unique-id - if (uidParts.length >= 2) { - Integer msgNum = Integer.valueOf(uidParts[0]); - String msgUid = uidParts[1]; - if (unindexedUids.contains(msgUid)) { - if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) { - Timber.d("Got msgNum %d for UID %s", msgNum, msgUid); - } - - Pop3Message message = mUidToMsgMap.get(msgUid); - if (message == null) { - message = new Pop3Message(msgUid, this); - } - indexMessage(msgNum, message); - } - } - } - } - - private void indexMessage(int msgNum, Pop3Message message) { - if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) { - Timber.d("Adding index for UID %s to msgNum %d", message.getUid(), msgNum); - } - mMsgNumToMsgMap.put(msgNum, message); - mUidToMsgMap.put(message.getUid(), message); - mUidToMsgNumMap.put(message.getUid(), msgNum); - } - - /** - * Fetch the items contained in the FetchProfile into the given set of - * Messages in as efficient a manner as possible. - * @param messages - * @param fp - * @throws MessagingException - */ - @Override - public void fetch(List messages, FetchProfile fp, MessageRetrievalListener listener) - throws MessagingException { - if (messages == null || messages.isEmpty()) { - return; - } - List uids = new ArrayList(); - for (Message message : messages) { - uids.add(message.getUid()); - } - try { - indexUids(uids); - } catch (IOException ioe) { - throw new MessagingException("fetch", ioe); - } - try { - if (fp.contains(FetchProfile.Item.ENVELOPE)) { - /* - * We pass the listener only if there are other things to do in the - * FetchProfile. Since fetchEnvelop works in bulk and eveything else - * works one at a time if we let fetchEnvelope send events the - * event would get sent twice. - */ - fetchEnvelope(messages, fp.size() == 1 ? listener : null); - } - } catch (IOException ioe) { - throw new MessagingException("fetch", ioe); - } - for (int i = 0, count = messages.size(); i < count; i++) { - Pop3Message pop3Message = messages.get(i); - try { - if (listener != null && !fp.contains(FetchProfile.Item.ENVELOPE)) { - listener.messageStarted(pop3Message.getUid(), i, count); - } - if (fp.contains(FetchProfile.Item.BODY)) { - fetchBody(pop3Message, -1); - } else if (fp.contains(FetchProfile.Item.BODY_SANE)) { - /* - * To convert the suggested download size we take the size - * divided by the maximum line size (76). - */ - if (mStoreConfig.getMaximumAutoDownloadMessageSize() > 0) { - fetchBody(pop3Message, - (mStoreConfig.getMaximumAutoDownloadMessageSize() / 76)); - } else { - fetchBody(pop3Message, -1); - } - } else if (fp.contains(FetchProfile.Item.STRUCTURE)) { - /* - * If the user is requesting STRUCTURE we are required to set the body - * to null since we do not support the function. - */ - pop3Message.setBody(null); - } - if (listener != null && !(fp.contains(FetchProfile.Item.ENVELOPE) && fp.size() == 1)) { - listener.messageFinished(pop3Message, i, count); - } - } catch (IOException ioe) { - throw new MessagingException("Unable to fetch message", ioe); - } - } - } - - private void fetchEnvelope(List messages, - MessageRetrievalListener listener) throws IOException, MessagingException { - int unsizedMessages = 0; - for (Message message : messages) { - if (message.getSize() == -1) { - unsizedMessages++; - } - } - if (unsizedMessages == 0) { - return; - } - if (unsizedMessages < 50 && mMessageCount > 5000) { - /* - * In extreme cases we'll do a command per message instead of a bulk request - * to hopefully save some time and bandwidth. - */ - for (int i = 0, count = messages.size(); i < count; i++) { - Pop3Message message = messages.get(i); - if (listener != null) { - listener.messageStarted(message.getUid(), i, count); - } - String response = executeSimpleCommand(String.format(Locale.US, LIST_COMMAND + " %d", - mUidToMsgNumMap.get(message.getUid()))); - String[] listParts = response.split(" "); - //int msgNum = Integer.parseInt(listParts[1]); - int msgSize = Integer.parseInt(listParts[2]); - message.setSize(msgSize); - if (listener != null) { - listener.messageFinished(message, i, count); - } - } - } else { - Set msgUidIndex = new HashSet(); - for (Message message : messages) { - msgUidIndex.add(message.getUid()); - } - int i = 0, count = messages.size(); - String response = executeSimpleCommand(LIST_COMMAND); - while ((response = readLine()) != null) { - if (response.equals(".")) { - break; - } - String[] listParts = response.split(" "); - int msgNum = Integer.parseInt(listParts[0]); - int msgSize = Integer.parseInt(listParts[1]); - Pop3Message pop3Message = mMsgNumToMsgMap.get(msgNum); - if (pop3Message != null && msgUidIndex.contains(pop3Message.getUid())) { - if (listener != null) { - listener.messageStarted(pop3Message.getUid(), i, count); - } - pop3Message.setSize(msgSize); - if (listener != null) { - listener.messageFinished(pop3Message, i, count); - } - i++; - } - } - } - } - - /** - * Fetches the body of the given message, limiting the downloaded data to the specified - * number of lines if possible. - * - * If lines is -1 the entire message is fetched. This is implemented with RETR for - * lines = -1 or TOP for any other value. If the server does not support TOP, RETR is used - * instead. - */ - private void fetchBody(Pop3Message message, int lines) - throws IOException, MessagingException { - String response = null; - - // Try hard to use the TOP command if we're not asked to download the whole message. - if (lines != -1 && (!mTopNotSupported || mCapabilities.top)) { - try { - if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3 && !mCapabilities.top) { - Timber.d("This server doesn't support the CAPA command. " + - "Checking to see if the TOP command is supported nevertheless."); - } - - response = executeSimpleCommand(String.format(Locale.US, TOP_COMMAND + " %d %d", - mUidToMsgNumMap.get(message.getUid()), lines)); - - // TOP command is supported. Remember this for the next time. - mCapabilities.top = true; - } catch (Pop3ErrorResponse e) { - if (mCapabilities.top) { - // The TOP command should be supported but something went wrong. - throw e; - } else { - if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) { - Timber.d("The server really doesn't support the TOP " + - "command. Using RETR instead."); - } - - // Don't try to use the TOP command again. - mTopNotSupported = true; - } - } - } - - if (response == null) { - executeSimpleCommand(String.format(Locale.US, RETR_COMMAND + " %d", - mUidToMsgNumMap.get(message.getUid()))); - } - - try { - message.parse(new Pop3ResponseInputStream(mIn)); - - // TODO: if we've received fewer lines than requested we also have the complete message. - if (lines == -1 || !mCapabilities.top) { - message.setFlag(Flag.X_DOWNLOADED_FULL, true); - } - } catch (MessagingException me) { - /* - * If we're only downloading headers it's possible - * we'll get a broken MIME message which we're not - * real worried about. If we've downloaded the body - * and can't parse it we need to let the user know. - */ - if (lines == -1) { - throw me; - } - } - } - - @Override - public Map appendMessages(List messages) throws MessagingException { - return null; - } - - @Override - public void delete(boolean recurse) throws MessagingException { - } - - @Override - public void delete(List msgs, String trashFolderName) throws MessagingException { - setFlags(msgs, Collections.singleton(Flag.DELETED), true); - } - - @Override - public String getUidFromMessageId(Message message) throws MessagingException { - return null; - } - - @Override - public void setFlags(final Set flags, boolean value) throws MessagingException { - throw new UnsupportedOperationException("POP3: No setFlags(Set,boolean)"); - } - - @Override - public void setFlags(List messages, final Set flags, boolean value) - throws MessagingException { - if (!value || !flags.contains(Flag.DELETED)) { - /* - * The only flagging we support is setting the Deleted flag. - */ - return; - } - List uids = new ArrayList(); - try { - for (Message message : messages) { - uids.add(message.getUid()); - } - - indexUids(uids); - } catch (IOException ioe) { - throw new MessagingException("Could not get message number for uid " + uids, ioe); - } - for (Message message : messages) { - - Integer msgNum = mUidToMsgNumMap.get(message.getUid()); - if (msgNum == null) { - MessagingException me = new MessagingException("Could not delete message " + message.getUid() - + " because no msgNum found; permanent error"); - me.setPermanentFailure(true); - throw me; - } - executeSimpleCommand(String.format(DELE_COMMAND + " %s", msgNum)); - } - } - - private String readLine() throws IOException { - StringBuilder sb = new StringBuilder(); - int d = mIn.read(); - if (d == -1) { - throw new IOException("End of stream reached while trying to read line."); - } - do { - if (((char)d) == '\r') { - continue; - } else if (((char)d) == '\n') { - break; - } else { - sb.append((char)d); - } - } while ((d = mIn.read()) != -1); - String ret = sb.toString(); - if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) { - Timber.d("<<< %s", ret); - } - return ret; - } - - private void writeLine(String s) throws IOException { - mOut.write(s.getBytes()); - mOut.write('\r'); - mOut.write('\n'); - mOut.flush(); - } - - private Pop3Capabilities getCapabilities() throws IOException { - Pop3Capabilities capabilities = new Pop3Capabilities(); - try { - /* - * Try sending an AUTH command with no arguments. - * - * The server may respond with a list of supported SASL - * authentication mechanisms. - * - * Ref.: http://tools.ietf.org/html/draft-myers-sasl-pop3-05 - * - * While this never became a standard, there are servers that - * support it, and Thunderbird includes this check. - */ - String response = executeSimpleCommand(AUTH_COMMAND); - while ((response = readLine()) != null) { - if (response.equals(".")) { - break; - } - response = response.toUpperCase(Locale.US); - if (response.equals(AUTH_PLAIN_CAPABILITY)) { - capabilities.authPlain = true; - } else if (response.equals(AUTH_CRAM_MD5_CAPABILITY)) { - capabilities.cramMD5 = true; - } else if (response.equals(AUTH_EXTERNAL_CAPABILITY)) { - capabilities.external = true; - } - } - } catch (MessagingException e) { - // Assume AUTH command with no arguments is not supported. - } - try { - String response = executeSimpleCommand(CAPA_COMMAND); - while ((response = readLine()) != null) { - if (response.equals(".")) { - break; - } - response = response.toUpperCase(Locale.US); - if (response.equals(STLS_CAPABILITY)) { - capabilities.stls = true; - } else if (response.equals(UIDL_CAPABILITY)) { - capabilities.uidl = true; - } else if (response.equals(TOP_CAPABILITY)) { - capabilities.top = true; - } else if (response.startsWith(SASL_CAPABILITY)) { - List saslAuthMechanisms = Arrays.asList(response.split(" ")); - if (saslAuthMechanisms.contains(AUTH_PLAIN_CAPABILITY)) { - capabilities.authPlain = true; - } - if (saslAuthMechanisms.contains(AUTH_CRAM_MD5_CAPABILITY)) { - capabilities.cramMD5 = true; - } - } - } - - if (!capabilities.top) { - /* - * If the CAPA command is supported but it doesn't advertise support for the - * TOP command, we won't check for it manually. - */ - mTopNotSupported = true; - } - } catch (MessagingException me) { - /* - * The server may not support the CAPA command, so we just eat this Exception - * and allow the empty capabilities object to be returned. - */ - } - return capabilities; - } - - private String executeSimpleCommand(String command) throws MessagingException { - return executeSimpleCommand(command, false); - } - - private String executeSimpleCommand(String command, boolean sensitive) throws MessagingException { - try { - open(Folder.OPEN_MODE_RW); - - if (command != null) { - if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) { - if (sensitive && !K9MailLib.isDebugSensitive()) { - Timber.d(">>> [Command Hidden, Enable Sensitive Debug Logging To Show]"); - } else { - Timber.d(">>> %s", command); - } - } - - writeLine(command); - } - - String response = readLine(); - if (response.length() == 0 || response.charAt(0) != '+') { - throw new Pop3ErrorResponse(response); - } - - return response; - } catch (MessagingException me) { - throw me; - } catch (Exception e) { - closeIO(); - throw new MessagingException("Unable to execute POP3 command", e); - } - } - - @Override - public boolean isFlagSupported(Flag flag) { - return (flag == Flag.DELETED); - } - - @Override - public boolean supportsFetchingFlags() { - return false; - } - - @Override - public boolean equals(Object o) { - if (o instanceof Pop3Folder) { - return ((Pop3Folder) o).mName.equals(mName); - } - return super.equals(o); - } - - @Override - public int hashCode() { - return mName.hashCode(); - } - - }//Pop3Folder - - static class Pop3Message extends MimeMessage { - Pop3Message(String uid, Pop3Folder folder) { - mUid = uid; - mFolder = folder; - mSize = -1; - } - - public void setSize(int size) { - mSize = size; - } - - @Override - public void setFlag(Flag flag, boolean set) throws MessagingException { - super.setFlag(flag, set); - mFolder.setFlags(Collections.singletonList(this), Collections.singleton(flag), set); - } - - @Override - public void delete(String trashFolderName) throws MessagingException { - // try - // { - // Poor POP3 users, we can't copy the message to the Trash folder, but they still want a delete - setFlag(Flag.DELETED, true); - // } -// catch (MessagingException me) -// { -// Log.w(LOG_TAG, "Could not delete non-existent message", me); -// } + public String getClientCertificateAlias() { + return clientCertificateAlias; } } - static class Pop3Capabilities { - public boolean cramMD5; - public boolean authPlain; - public boolean stls; - public boolean top; - public boolean uidl; - public boolean external; - - @Override - public String toString() { - return String.format("CRAM-MD5 %b, PLAIN %b, STLS %b, TOP %b, UIDL %b, EXTERNAL %b", - cramMD5, - authPlain, - stls, - top, - uidl, - external); - } - } - - static class Pop3ResponseInputStream extends InputStream { - private InputStream mIn; - private boolean mStartOfLine = true; - private boolean mFinished; - - public Pop3ResponseInputStream(InputStream in) { - mIn = in; - } - - @Override - public int read() throws IOException { - if (mFinished) { - return -1; - } - int d = mIn.read(); - if (mStartOfLine && d == '.') { - d = mIn.read(); - if (d == '\r') { - mFinished = true; - mIn.read(); - return -1; - } - } - - mStartOfLine = (d == '\n'); - - return d; - } - } - - /** - * Exception that is thrown if the server returns an error response. - */ - static class Pop3ErrorResponse extends MessagingException { - private static final long serialVersionUID = 3672087845857867174L; - - public Pop3ErrorResponse(String message) { - super(message, true); - } - } } diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/MockPop3Server.java b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/MockPop3Server.java new file mode 100644 index 000000000..0742016b0 --- /dev/null +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/MockPop3Server.java @@ -0,0 +1,423 @@ +package com.fsck.k9.mail.store.pop3; + + +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 android.annotation.SuppressLint; + +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; + + +@SuppressLint("NewApi") +public class MockPop3Server { + private static final byte[] CRLF = { '\r', '\n' }; + + + private final Deque 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 MockPop3Server() { + this(KeyStoreProvider.getInstance(), new DefaultLogger()); + } + + public MockPop3Server(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 enableCompression() { + checkServerNotRunning(); + interactions.add(new EnableCompression()); + } + + 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 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 ImapInteraction {} + + private static class ExpectedCommand implements ImapInteraction { + private final String command; + + + public ExpectedCommand(String command) { + this.command = command; + } + + public String getCommand() { + return command; + } + } + + private static class CannedResponse implements ImapInteraction { + private final String response; + + + public CannedResponse(String response) { + this.response = response; + } + + public String getResponse() { + return response; + } + } + + private static class CloseConnection implements ImapInteraction { + } + + private static class EnableCompression implements ImapInteraction { + } + + private static class UpgradeToTls implements ImapInteraction { + } + + 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 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 interactions, + CountDownLatch waitForConnectionClosed, CountDownLatch waitForAllExpectedCommands, Logger logger, + KeyStoreProvider keyStoreProvider) { + super("MockImapServer"); + 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 { + + ImapInteraction interaction = interactions.pop(); + if (interaction instanceof ExpectedCommand) { + readExpectedCommand((ExpectedCommand) interaction); + } else if (interaction instanceof CannedResponse) { + writeCannedResponse((CannedResponse) interaction); + } else if (interaction instanceof CloseConnection) { + clientSocket.close(); + } else if (interaction instanceof EnableCompression) { + enableCompression(socket); + } else if (interaction instanceof UpgradeToTls) { + upgradeToTls(socket); + } + } + + 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); + 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 isClientConnectionClosed() { + return clientSocket.isClosed(); + } + + public UnexpectedCommandException getUnexpectedCommandException() { + return unexpectedCommandException; + } + } + + private static class DefaultLogger implements Logger { + @Override + public void log(String message) { + System.out.println("MockPop3Server: " + message); + } + + @Override + public void log(String format, Object... args) { + log(String.format(format, args)); + } + } +} diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3CapabilitiesTest.java b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3CapabilitiesTest.java new file mode 100644 index 000000000..2db7f6e02 --- /dev/null +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3CapabilitiesTest.java @@ -0,0 +1,19 @@ +package com.fsck.k9.mail.store.pop3; + + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +public class Pop3CapabilitiesTest { + + @Test + public void toString_producesReadableOutput() { + String result = new Pop3Capabilities().toString();; + + assertEquals( + "CRAM-MD5 false, PLAIN false, STLS false, TOP false, UIDL false, EXTERNAL false", + result); + } +} diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.java b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.java new file mode 100644 index 000000000..2954ca719 --- /dev/null +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3ConnectionTest.java @@ -0,0 +1,490 @@ +package com.fsck.k9.mail.store.pop3; + + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.Socket; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; + +import com.fsck.k9.mail.AuthType; +import com.fsck.k9.mail.AuthenticationFailedException; +import com.fsck.k9.mail.CertificateValidationException; +import com.fsck.k9.mail.CertificateValidationException.Reason; +import com.fsck.k9.mail.ConnectionSecurity; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.filter.Base64; +import com.fsck.k9.mail.helpers.TestTrustedSocketFactory; +import com.fsck.k9.mail.ssl.TrustedSocketFactory; +import javax.net.ssl.SSLException; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + + +public class Pop3ConnectionTest { + + private static final String host = "server"; + private static final int port = 12345; + private static String username = "user"; + private static String password = "password"; + private static final String INITIAL_RESPONSE = "+OK POP3 server greeting\r\n"; + private static final String AUTH = "AUTH\r\n"; + private static final String AUTH_HANDLE_RESPONSE = "+OK Listing of supported mechanisms follows\r\n" + + "PLAIN\r\n" + + "CRAM-MD5\r\n" + + "EXTERNAL\r\n" + + ".\r\n"; + private static final String AUTH_NO_AUTH_PLAIN_RESPONSE = "+OK Listing of supported mechanisms follows\r\n" + + "CRAM-MD5\r\n" + + "EXTERNAL\r\n" + + ".\r\n"; + private static final String CAPA = "CAPA\r\n"; + private static final String CAPA_RESPONSE = "+OK Listing of supported mechanisms follows\r\n" + + "PLAIN\r\n" + + "CRAM-MD5\r\n" + + "EXTERNAL\r\n" + + ".\r\n"; + private static final String CAPA_NO_AUTH_PLAIN_RESPONSE = "+OK Listing of supported mechanisms follows\r\n" + + "CRAM-MD5\r\n" + + "EXTERNAL\r\n" + + ".\r\n"; + private static final String LOGIN_AUTHENTICATED_RESPONSE = "+OK\r\n" + "+OK\r\n"; + private static final String LOGIN = "USER "+username+"\r\n" + "PASS "+password+"\r\n"; + private static final String AUTH_PLAIN_WITH_LOGIN = "AUTH PLAIN\r\n" + + new String(Base64.encodeBase64(("\000"+username+"\000"+password).getBytes())) + "\r\n"; + private static final String AUTH_PLAIN_AUTHENTICATED_RESPONSE = "+OK\r\n" + "+OK\r\n"; + private static final String AUTH_PLAIN_FAILED_RESPONSE = "+OK\r\n" + "Plain authentication failure"; + private static final String STAT = "STAT\r\n"; + private static final String STAT_RESPONSE = "+OK 20 0\r\n"; + private static final String UIDL_UNSUPPORTED_RESPONSE = "-ERR UIDL unsupported\r\n"; + private static final String UIDL_SUPPORTED_RESPONSE = "+OK UIDL supported\r\n"; + + private TrustedSocketFactory mockTrustedSocketFactory; + private Socket mockSocket; + private ByteArrayOutputStream outputStream; + private SimplePop3Settings settings; + private TrustedSocketFactory socketFactory; + + @Before + public void before() throws Exception { + settings = new SimplePop3Settings(); + settings.setUsername(username); + settings.setPassword(password); + socketFactory = TestTrustedSocketFactory.newInstance(); + mockTrustedSocketFactory = mock(TrustedSocketFactory.class); //TODO: Remove + mockSocket = mock(Socket.class); //TODO: Remove + outputStream = new ByteArrayOutputStream(); //TODO: Remove + when(mockTrustedSocketFactory.createSocket(null, "server", 12345, null)).thenReturn(mockSocket); //TODO: Remove + when(mockSocket.getOutputStream()).thenReturn(outputStream); //TODO: Remove + when(mockSocket.isConnected()).thenReturn(true); //TODO: Remove + } + + private void setSettingsForMockSocket() { + settings.setHost(host); + settings.setPort(port); + settings.setConnectionSecurity(ConnectionSecurity.SSL_TLS_REQUIRED); + } + + @Test(expected = CertificateValidationException.class) + public void whenTrustedSocketFactoryThrowsSSLCertificateException_throwCertificateValidationException() throws Exception { + when(mockTrustedSocketFactory.createSocket(null, "server", 12345, null)).thenThrow( + new SSLException(new CertificateException())); + setSettingsForMockSocket(); + settings.setAuthType(AuthType.PLAIN); + + new Pop3Connection(settings, mockTrustedSocketFactory); + } + + @Test(expected = MessagingException.class) + public void whenTrustedSocketFactoryThrowsCertificateException_throwMessagingException() throws Exception { + when(mockTrustedSocketFactory.createSocket(null, "server", 12345, null)).thenThrow( + new SSLException("")); + + setSettingsForMockSocket(); + settings.setAuthType(AuthType.PLAIN); + + new Pop3Connection(settings, mockTrustedSocketFactory); + } + + @Test(expected = MessagingException.class) + public void whenTrustedSocketFactoryThrowsGeneralSecurityException_throwMessagingException() throws Exception { + when(mockTrustedSocketFactory.createSocket(null, "server", 12345, null)).thenThrow( + new NoSuchAlgorithmException("")); + + setSettingsForMockSocket(); + settings.setAuthType(AuthType.PLAIN); + + new Pop3Connection(settings, mockTrustedSocketFactory); + } + + @Test(expected = MessagingException.class) + public void whenTrustedSocketFactoryThrowsIOException_throwMessagingException() throws Exception { + when(mockTrustedSocketFactory.createSocket(null, "server", 12345, null)).thenThrow( + new IOException("")); + + setSettingsForMockSocket(); + settings.setAuthType(AuthType.PLAIN); + + new Pop3Connection(settings, mockTrustedSocketFactory); + } + + @Test(expected = MessagingException.class) + public void whenSocketNotConnected_throwsMessagingException() throws Exception { + when(mockSocket.isConnected()).thenReturn(false); + + setSettingsForMockSocket(); + settings.setAuthType(AuthType.PLAIN); + + new Pop3Connection(settings, mockTrustedSocketFactory); + } + + @Test + public void withTLS_connectsToSocket() throws Exception { + String response = INITIAL_RESPONSE + + AUTH_HANDLE_RESPONSE + + CAPA_RESPONSE + + AUTH_PLAIN_AUTHENTICATED_RESPONSE; + + when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(response.getBytes())); + setSettingsForMockSocket(); + settings.setAuthType(AuthType.PLAIN); + + new Pop3Connection(settings, mockTrustedSocketFactory); + + assertEquals(AUTH + + CAPA + + AUTH_PLAIN_WITH_LOGIN, new String(outputStream.toByteArray())); + } + + @Test + public void withAuthTypePlainAndPlainAuthCapability_performsPlainAuth() throws Exception { + settings.setAuthType(AuthType.PLAIN); + + MockPop3Server server = new MockPop3Server(); + server.output("+OK POP3 server greeting"); + server.expect("AUTH"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("CAPA"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("AUTH PLAIN"); + server.output("+OK"); + server.expect(new String(Base64.encodeBase64(("\000"+username+"\000"+password).getBytes()))); + server.output("+OK"); + startServerAndCreateConnection(server); + + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + + @Test + public void withAuthTypePlainAndPlainAuthCapabilityAndInvalidPasswordResponse_throwsException() throws Exception { + settings.setAuthType(AuthType.PLAIN); + + MockPop3Server server = new MockPop3Server(); + server.output("+OK POP3 server greeting"); + server.expect("AUTH"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("CAPA"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("AUTH PLAIN"); + server.output("+OK"); + server.expect(new String(Base64.encodeBase64(("\000"+username+"\000"+password).getBytes()))); + server.output("-ERR"); + + try { + startServerAndCreateConnection(server); + fail("Expected auth failure"); + } catch (AuthenticationFailedException ignored) {} + + server.verifyInteractionCompleted(); + } + + @Test + public void withAuthTypePlainAndNoPlainAuthCapability_performsLogin() throws Exception { + settings.setAuthType(AuthType.PLAIN); + + MockPop3Server server = new MockPop3Server(); + server.output("+OK POP3 server greeting"); + server.expect("AUTH"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("CAPA"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("USER user"); + server.output("+OK"); + server.expect("PASS password"); + server.output("-ERR"); + + try { + startServerAndCreateConnection(server); + fail("Expected auth failure"); + } catch (AuthenticationFailedException ignored) {} + + server.verifyInteractionCompleted(); + } + + @Test + public void withAuthTypePlainAndNoPlainAuthCapabilityAndLoginFailure_throwsException() throws Exception { + settings.setAuthType(AuthType.PLAIN); + + MockPop3Server server = new MockPop3Server(); + server.output("+OK POP3 server greeting"); + server.expect("AUTH"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("CAPA"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("USER user"); + server.output("+OK"); + server.expect("PASS password"); + server.output("+OK"); + + startServerAndCreateConnection(server); + + server.verifyInteractionCompleted(); + } + + @Test + public void withAuthTypeCramMd5AndCapability_performsCramMd5Auth() throws IOException, MessagingException { + settings.setAuthType(AuthType.CRAM_MD5); + + + MockPop3Server server = new MockPop3Server(); + server.output("+OK POP3 server greeting"); + server.expect("AUTH"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("CAPA"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("AUTH CRAM-MD5"); + server.output("+ abcd"); + server.expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg=="); + server.output("+OK"); + startServerAndCreateConnection(server); + + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + + @Test + public void withAuthTypeCramMd5AndCapabilityAndCramFailure_throwsException() throws IOException, MessagingException { + settings.setAuthType(AuthType.CRAM_MD5); + + + MockPop3Server server = new MockPop3Server(); + server.output("+OK POP3 server greeting"); + server.expect("AUTH"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("CAPA"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("AUTH CRAM-MD5"); + server.output("+ abcd"); + server.expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg=="); + server.output("-ERR"); + + try { + startServerAndCreateConnection(server); + fail("Expected auth failure"); + } catch (AuthenticationFailedException ignored) {} + + server.verifyInteractionCompleted(); + } + + @Test + public void withAuthTypeCramMd5AndNoCapability_performsApopAuth() throws IOException, MessagingException { + settings.setAuthType(AuthType.CRAM_MD5); + + MockPop3Server server = new MockPop3Server(); + server.output("+OK abcabcd"); + server.expect("AUTH"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("EXTERNAL"); + server.output("."); + server.expect("CAPA"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("EXTERNAL"); + server.output("."); + server.expect("APOP user c8e8c560e385faaa6367d4145572b8ea"); + server.output("+OK"); + startServerAndCreateConnection(server); + + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + + @Test + public void withAuthTypeCramMd5AndNoCapabilityAndApopFailure_throwsException() throws IOException, MessagingException { + settings.setAuthType(AuthType.CRAM_MD5); + + + MockPop3Server server = new MockPop3Server(); + server.output("+OK abcabcd"); + server.expect("AUTH"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("EXTERNAL"); + server.output("."); + server.expect("CAPA"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("EXTERNAL"); + server.output("."); + server.expect("APOP user c8e8c560e385faaa6367d4145572b8ea"); + server.output("-ERR"); + + try { + startServerAndCreateConnection(server); + fail("Expected auth failure"); + } catch (AuthenticationFailedException ignored) {} + + server.verifyInteractionCompleted(); + } + + @Test + public void withAuthTypeExternalAndCapability_performsExternalAuth() throws IOException, MessagingException { + settings.setAuthType(AuthType.EXTERNAL); + + MockPop3Server server = new MockPop3Server(); + server.output("+OK POP3 server greeting"); + server.expect("AUTH"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("CAPA"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("AUTH EXTERNAL dXNlcg=="); + server.output("+OK"); + startServerAndCreateConnection(server); + + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + + @Test + public void withAuthTypeExternalAndNoCapability_throwsCVE() throws IOException, MessagingException { + settings.setAuthType(AuthType.EXTERNAL); + + MockPop3Server server = new MockPop3Server(); + server.output("+OK POP3 server greeting"); + server.expect("AUTH"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("."); + server.expect("CAPA"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + + try { + startServerAndCreateConnection(server); + fail("CVE expected"); + } catch (CertificateValidationException e) { + assertEquals(Reason.MissingCapability, e.getReason()); + } + + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + + @Test + public void withAuthTypeExternalAndCapability_withRejection_throwsCVE() throws IOException, MessagingException { + settings.setAuthType(AuthType.EXTERNAL); + + MockPop3Server server = new MockPop3Server(); + server.output("+OK POP3 server greeting"); + server.expect("AUTH"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("CAPA"); + server.output("+OK Listing of supported mechanisms follows"); + server.output("PLAIN"); + server.output("CRAM-MD5"); + server.output("EXTERNAL"); + server.output("."); + server.expect("AUTH EXTERNAL dXNlcg=="); + server.output("-ERR Invalid certificate"); + + try { + startServerAndCreateConnection(server); + fail("CVE expected"); + } catch (CertificateValidationException e) { + assertEquals("POP3 client certificate authentication failed: -ERR Invalid certificate", e.getMessage()); + } + + server.verifyInteractionCompleted(); + } + + private Pop3Connection startServerAndCreateConnection(MockPop3Server server) throws IOException, + MessagingException { + server.start(); + settings.setHost(server.getHost()); + settings.setPort(server.getPort()); + return createPop3Connection(settings, socketFactory); + } + + private Pop3Connection createPop3Connection(Pop3Settings settings, TrustedSocketFactory socketFactory) + throws MessagingException { + return new Pop3Connection(settings, socketFactory); + } +} diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.java b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.java new file mode 100644 index 000000000..c8d9af2b9 --- /dev/null +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3FolderTest.java @@ -0,0 +1,329 @@ +package com.fsck.k9.mail.store.pop3; + + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import com.fsck.k9.mail.FetchProfile; +import com.fsck.k9.mail.FetchProfile.Item; +import com.fsck.k9.mail.Folder; +import com.fsck.k9.mail.Folder.FolderType; +import com.fsck.k9.mail.MessageRetrievalListener; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.internet.BinaryTempFileBody; +import com.fsck.k9.mail.store.StoreConfig; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + + +public class Pop3FolderTest { + private Pop3Store mockStore; + private Pop3Connection mockConnection; + private StoreConfig mockStoreConfig; + private MessageRetrievalListener mockListener; + private Pop3Folder folder; + + @Before + public void before() throws MessagingException { + mockStore = mock(Pop3Store.class); + mockConnection = mock(Pop3Connection.class); + mockStoreConfig = mock(StoreConfig.class); + mockListener = mock(MessageRetrievalListener.class); + when(mockStore.getConfig()).thenReturn(mockStoreConfig); + when(mockStoreConfig.getInboxFolderName()).thenReturn("Inbox"); + when(mockStore.createConnection()).thenReturn(mockConnection); + when(mockConnection.executeSimpleCommand(Pop3Commands.STAT_COMMAND)).thenReturn("+OK 10 0"); + folder = new Pop3Folder(mockStore, "Inbox"); + setupTempDirectory(); + } + + private void setupTempDirectory() { + File tempDirectory = new File("temp"); + if (!tempDirectory.exists()) { + assertTrue(tempDirectory.mkdir()); + tempDirectory.deleteOnExit(); + } + BinaryTempFileBody.setTempDirectory(tempDirectory); + } + + @Test + public void create_withHoldsFoldersArgument_shouldDoNothing() throws Exception { + Pop3Folder folder = new Pop3Folder(mockStore, "TestFolder"); + + boolean result = folder.create(FolderType.HOLDS_FOLDERS); + + assertFalse(result); + verifyZeroInteractions(mockConnection); + } + + @Test + public void create_withHoldsMessagesArgument_shouldDoNothing() throws Exception { + Pop3Folder folder = new Pop3Folder(mockStore, "TestFolder"); + + boolean result = folder.create(FolderType.HOLDS_MESSAGES); + + assertFalse(result); + verifyZeroInteractions(mockConnection); + } + + @Test + public void exists_withInbox_shouldReturnTrue() throws Exception { + boolean result = folder.exists(); + + assertTrue(result); + } + + @Test + public void exists_withNonInboxFolder_shouldReturnFalse() throws Exception { + folder = new Pop3Folder(mockStore, "TestFolder"); + + boolean result = folder.exists(); + + assertFalse(result); + } + + @Test + public void getUnreadMessageCount_shouldBeMinusOne() throws Exception { + int result = folder.getUnreadMessageCount(); + + assertEquals(-1, result); + } + + @Test + public void getFlaggedMessageCount_shouldBeMinusOne() throws Exception { + int result = folder.getFlaggedMessageCount(); + + assertEquals(-1, result); + } + + @Test(expected = MessagingException.class) + public void open_withoutInboxFolder_shouldThrow() throws Exception { + Pop3Folder folder = new Pop3Folder(mockStore, "TestFolder"); + + folder.open(Folder.OPEN_MODE_RW); + } + + @Test + public void open_withoutInboxFolder_shouldNotTryAndCreateConnection() throws Exception { + Pop3Folder folder = new Pop3Folder(mockStore, "TestFolder"); + try { + folder.open(Folder.OPEN_MODE_RW); + } catch (Exception ignored) {} + verify(mockStore, never()).createConnection(); + } + + @Test(expected = MessagingException.class) + public void open_withInboxFolderWithExceptionCreatingConnection_shouldThrow() + throws MessagingException { + + when(mockStore.createConnection()).thenThrow(new MessagingException("Test")); + folder.open(Folder.OPEN_MODE_RW); + } + + @Test + public void open_withInboxFolder_shouldSetMessageCountFromStatResponse() + throws MessagingException { + folder.open(Folder.OPEN_MODE_RW); + + int messageCount = folder.getMessageCount(); + + assertEquals(10, messageCount); + } + + @Test(expected = MessagingException.class) + public void open_withInboxFolder_whenStatCommandFails_shouldThrow() + throws MessagingException { + when(mockConnection.executeSimpleCommand(Pop3Commands.STAT_COMMAND)) + .thenThrow(new MessagingException("Test")); + + folder.open(Folder.OPEN_MODE_RW); + } + + @Test + public void open_whenAlreadyOpenWithValidConnection_doesNotCreateAnotherConnection() + throws MessagingException { + folder.open(Folder.OPEN_MODE_RW); + when(mockConnection.isOpen()).thenReturn(true); + + folder.open(Folder.OPEN_MODE_RW); + + verify(mockStore, times(1)).createConnection(); + } + + @Test + public void getMode_withFolderOpenedInRO_isRW() throws MessagingException { + + folder.open(Folder.OPEN_MODE_RO); + + int mode = folder.getMode(); + + assertEquals(Folder.OPEN_MODE_RW, mode); + } + + @Test + public void close_onNonOpenedFolder_succeeds() + throws MessagingException { + + + folder.close(); + } + + @Test + public void close_onOpenedFolder_succeeds() + throws MessagingException { + + folder.open(Folder.OPEN_MODE_RW); + + folder.close(); + } + + @Test + public void close_onOpenedFolder_sendsQUIT() + throws MessagingException { + + folder.open(Folder.OPEN_MODE_RW); + when(mockConnection.isOpen()).thenReturn(true); + + folder.close(); + + verify(mockConnection).executeSimpleCommand(Pop3Commands.QUIT_COMMAND); + } + + @Test + public void close_withExceptionQuiting_ignoresException() + throws MessagingException { + + folder.open(Folder.OPEN_MODE_RW); + when(mockConnection.isOpen()).thenReturn(true); + doThrow(new MessagingException("Test")) + .when(mockConnection) + .executeSimpleCommand(Pop3Commands.QUIT_COMMAND); + + folder.close(); + } + + @Test + public void close_onOpenedFolder_closesConnection() + throws MessagingException { + + folder.open(Folder.OPEN_MODE_RW); + when(mockConnection.isOpen()).thenReturn(true); + + folder.close(); + + verify(mockConnection).close(); + } + + @Test + public void getMessages_returnsListOfMessagesOnServer() throws IOException, MessagingException { + folder.open(Folder.OPEN_MODE_RW); + + when(mockConnection.readLine()).thenReturn("1 abcd").thenReturn("."); + + List result = folder.getMessages(1, 1, null, mockListener); + + assertEquals(1, result.size()); + } + + @Test(expected = MessagingException.class) + public void getMessages_withInvalidSet_throwsException() throws IOException, MessagingException { + folder.open(Folder.OPEN_MODE_RW); + + folder.getMessages(2, 1, null, mockListener); + } + + @Test(expected = MessagingException.class) + public void getMessages_withIOExceptionReadingLine_throwsException() throws IOException, MessagingException { + folder.open(Folder.OPEN_MODE_RW); + + when(mockConnection.readLine()).thenThrow(new IOException("Test")); + + folder.getMessages(1, 1, null, mockListener); + } + + @Test + public void getMessage_withPreviouslyFetchedMessage_returnsMessage() + throws IOException, MessagingException { + folder.open(Folder.OPEN_MODE_RW); + + List messageList = setupMessageFromServer(); + + Pop3Message message = folder.getMessage("abcd"); + + assertSame(messageList.get(0), message); + } + + @Test + public void getMessage_withNoPreviouslyFetchedMessage_returnsNewMessage() + throws IOException, MessagingException { + folder.open(Folder.OPEN_MODE_RW); + + Pop3Message message = folder.getMessage("abcd"); + + assertNotNull(message); + } + + + @Test + public void fetch_withEnvelopeProfile_setsSizeOfMessage() throws MessagingException, IOException { + folder.open(Folder.OPEN_MODE_RW); + List messageList = setupMessageFromServer(); + FetchProfile fetchProfile = new FetchProfile(); + fetchProfile.add(Item.ENVELOPE); + when(mockConnection.readLine()).thenReturn("1 100").thenReturn("."); + + folder.fetch(messageList, fetchProfile, mockListener); + + assertEquals(100, messageList.get(0).getSize()); + } + + @Test + public void fetch_withBodyProfile_setsContentOfMessage() throws MessagingException, IOException { + InputStream messageInputStream = new ByteArrayInputStream(( + "From: \r\n" + + "To: \r\n" + + "Subject: Testmail\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-type: text/plain\r\n" + + "Content-Transfer-Encoding: 7bit\r\n" + + "\r\n" + + "this is some test text.").getBytes()); + folder.open(Folder.OPEN_MODE_RW); + List messageList = setupMessageFromServer(); + FetchProfile fetchProfile = new FetchProfile(); + fetchProfile.add(Item.BODY); + when(mockConnection.readLine()).thenReturn("1 100").thenReturn("."); + when(mockConnection.getInputStream()).thenReturn(messageInputStream); + + folder.fetch(messageList, fetchProfile, mockListener); + + ByteArrayOutputStream bodyData = new ByteArrayOutputStream(); + messageList.get(0).getBody().writeTo(bodyData); + + assertEquals("this is some test text.", new String(bodyData.toByteArray(), "UTF-8")); + } + + private List setupMessageFromServer() throws IOException, MessagingException { + when(mockConnection.readLine()).thenReturn("1 abcd").thenReturn("."); + return folder.getMessages(1, 1, null, mockListener); + } +} diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3MessageTest.java b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3MessageTest.java new file mode 100644 index 000000000..1b1bf1b50 --- /dev/null +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3MessageTest.java @@ -0,0 +1,21 @@ +package com.fsck.k9.mail.store.pop3; + + +import com.fsck.k9.mail.Flag; +import com.fsck.k9.mail.MessagingException; +import org.junit.Test; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + + +public class Pop3MessageTest { + + @Test + public void delete_setsDeletedFlag() throws MessagingException { + Pop3Message message = new Pop3Message("001", mock(Pop3Folder.class)); + message.delete("Trash"); + + assertTrue(message.getFlags().contains(Flag.DELETED)); + } +} diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.java b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.java index 977648a9f..4a9c2601e 100644 --- a/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.java +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/Pop3StoreTest.java @@ -3,14 +3,18 @@ package com.fsck.k9.mail.store.pop3; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.OutputStream; import java.net.Socket; import java.util.List; +import com.fsck.k9.mail.AuthType; import com.fsck.k9.mail.AuthenticationFailedException; +import com.fsck.k9.mail.ConnectionSecurity; import com.fsck.k9.mail.Folder; -import com.fsck.k9.mail.Folder.FolderType; import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.ServerSettings; +import com.fsck.k9.mail.ServerSettings.Type; import com.fsck.k9.mail.filter.Base64; import com.fsck.k9.mail.ssl.TrustedSocketFactory; import com.fsck.k9.mail.store.StoreConfig; @@ -20,9 +24,10 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.anyString; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; @@ -46,6 +51,8 @@ public class Pop3StoreTest { private static final String AUTH_PLAIN_FAILED_RESPONSE = "+OK\r\n" + "Plain authentication failure"; private static final String STAT = "STAT\r\n"; private static final String STAT_RESPONSE = "+OK 20 0\r\n"; + private static final String UIDL_UNSUPPORTED_RESPONSE = "-ERR UIDL unsupported\r\n"; + private static final String UIDL_SUPPORTED_RESPONSE = "+OK UIDL supported\r\n"; private Pop3Store store; @@ -68,80 +75,116 @@ public class Pop3StoreTest { store = new Pop3Store(mockStoreConfig, mockTrustedSocketFactory); } + @Test + public void decodeUri_withTLSUri_shouldUseStartTls() { + ServerSettings settings = Pop3Store.decodeUri("pop3+tls+://PLAIN:user:password@server:12345"); + + assertEquals(settings.connectionSecurity, ConnectionSecurity.STARTTLS_REQUIRED); + } + + @Test + public void decodeUri_withPlainUri_shouldUseNoSecurity() { + ServerSettings settings = Pop3Store.decodeUri("pop3://PLAIN:user:password@server:12345"); + assertEquals(settings.connectionSecurity, ConnectionSecurity.NONE); + } + + @Test + public void decodeUri_withExternalCertificateShouldProvideAlias_shouldUseNoSecurity() { + ServerSettings settings = Pop3Store.decodeUri("pop3://EXTERNAL:user:clientCert@server:12345"); + + assertEquals(settings.clientCertificateAlias, "clientCert"); + } + + @Test(expected = IllegalArgumentException.class) + public void decodeUri_withNonPop3Uri_shouldThrowException() { + Pop3Store.decodeUri("imap://PLAIN:user:password@server:12345"); + } + + @Test + public void createUri_withSSLTLS_required_shouldProduceSSLUri() { + ServerSettings settings = new ServerSettings(Type.POP3, "server", 12345, ConnectionSecurity.SSL_TLS_REQUIRED, + AuthType.PLAIN, "user", "password", null); + + String uri = Pop3Store.createUri(settings); + + assertEquals(uri, "pop3+ssl+://PLAIN:user:password@server:12345"); + } + + @Test + public void createUri_withSTARTTLSRequired_shouldProduceTLSUri() { + ServerSettings settings = new ServerSettings(Type.POP3, "server", 12345, ConnectionSecurity.STARTTLS_REQUIRED, + AuthType.PLAIN, "user", "password", null); + + String uri = Pop3Store.createUri(settings); + + assertEquals(uri, "pop3+tls+://PLAIN:user:password@server:12345"); + } + + @Test + public void createUri_withNONE_shouldProducePop3Uri() { + ServerSettings settings = new ServerSettings(Type.POP3, "server", 12345, ConnectionSecurity.NONE, + AuthType.PLAIN, "user", "password", null); + + String uri = Pop3Store.createUri(settings); + + assertEquals(uri, "pop3://PLAIN:user:password@server:12345"); + } + + @Test + public void createUri_withPLAIN_shouldProducePlainAuthUri() { + ServerSettings settings = new ServerSettings(Type.POP3, "server", 12345, ConnectionSecurity.NONE, + AuthType.PLAIN, "user", "password", null); + + String uri = Pop3Store.createUri(settings); + + assertEquals(uri, "pop3://PLAIN:user:password@server:12345"); + } + + @Test + public void createUri_withEXTERNAL_shouldProduceExternalAuthUri() { + ServerSettings settings = new ServerSettings(Type.POP3, "server", 12345, ConnectionSecurity.NONE, + AuthType.EXTERNAL, "user", "password", "clientCert"); + + String uri = Pop3Store.createUri(settings); + + assertEquals(uri, "pop3://EXTERNAL:user:clientCert@server:12345"); + } + + @Test + public void createUri_withCRAMMD5_shouldProduceCRAMMD5AuthUri() { + ServerSettings settings = new ServerSettings(Type.POP3, "server", 12345, ConnectionSecurity.NONE, + AuthType.CRAM_MD5, "user", "password", "clientCert"); + + String uri = Pop3Store.createUri(settings); + + assertEquals(uri, "pop3://CRAM_MD5:user:password@server:12345"); + } + + + @Test(expected = MessagingException.class) + public void withInvalidStoreUri_shouldThrowMessagingException() throws MessagingException { + when(mockStoreConfig.getStoreUri()).thenReturn("pop3://CRAM_MD5:user:password@[]:12345"); + store = new Pop3Store(mockStoreConfig, mockTrustedSocketFactory); + } + @Test public void getFolder_shouldReturnSameFolderEachTime() { - Folder folderOne = store.getFolder("TestFolder"); - Folder folderTwo = store.getFolder("TestFolder"); + Pop3Folder folderOne = store.getFolder("TestFolder"); + Pop3Folder folderTwo = store.getFolder("TestFolder"); assertSame(folderOne, folderTwo); } @Test public void getFolder_shouldReturnFolderWithCorrectName() throws Exception { - Folder folder = store.getFolder("TestFolder"); + Pop3Folder folder = store.getFolder("TestFolder"); assertEquals("TestFolder", folder.getName()); } - @Test - public void create_withHoldsFoldersArgument_shouldDoNothing() throws Exception { - Folder folder = store.getFolder("TestFolder"); - - boolean result = folder.create(FolderType.HOLDS_FOLDERS); - - assertFalse(result); - verifyZeroInteractions(mockSocket); - } - - @Test - public void create_withHoldsMessagesArgument_shouldDoNothing() throws Exception { - Folder folder = store.getFolder("TestFolder"); - - boolean result = folder.create(FolderType.HOLDS_MESSAGES); - - assertFalse(result); - verifyZeroInteractions(mockSocket); - } - - @Test - public void exists_withInbox_shouldReturnTrue() throws Exception { - Folder inbox = store.getFolder("Inbox"); - - boolean result = inbox.exists(); - - assertTrue(result); - } - - @Test - public void exists_withNonInboxFolder_shouldReturnFalse() throws Exception { - Folder folder = store.getFolder("TestFolder"); - - boolean result = folder.exists(); - - assertFalse(result); - } - - @Test - public void getUnreadMessageCount_shouldBeMinusOne() throws Exception { - Folder inbox = store.getFolder("Inbox"); - - int result = inbox.getUnreadMessageCount(); - - assertEquals(-1, result); - } - - @Test - public void getFlaggedMessageCount_shouldBeMinusOne() throws Exception { - Folder inbox = store.getFolder("Inbox"); - - int result = inbox.getFlaggedMessageCount(); - - assertEquals(-1, result); - } - @Test public void getPersonalNamespace_shouldReturnListConsistingOfInbox() throws Exception { - List folders = store.getPersonalNamespaces(true); + List folders = store.getPersonalNamespaces(true); assertEquals(1, folders.size()); assertEquals("Inbox", folders.get(0).getName()); @@ -155,12 +198,45 @@ public class Pop3StoreTest { } @Test(expected = MessagingException.class) - public void open_withoutInboxFolder_shouldThrow() throws Exception { - Folder folder = store.getFolder("TestFolder"); - - folder.open(Folder.OPEN_MODE_RW); + public void checkSetting_whenConnectionThrowsException_shouldThrowMessagingException() + throws Exception { + when(mockTrustedSocketFactory.createSocket(any(Socket.class), + anyString(), anyInt(), anyString())).thenThrow(new IOException("Test")); + store.checkSettings(); } + @Test(expected = MessagingException.class) + public void checkSetting_whenUidlUnsupported_shouldThrowMessagingException() + throws Exception { + String response = INITIAL_RESPONSE + + AUTH_HANDLE_RESPONSE + + CAPA_RESPONSE + + AUTH_PLAIN_AUTHENTICATED_RESPONSE + + STAT_RESPONSE + + UIDL_UNSUPPORTED_RESPONSE; + when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(response.getBytes("UTF-8"))); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + when(mockSocket.getOutputStream()).thenReturn(byteArrayOutputStream); + store.checkSettings(); + } + + @Test + public void checkSetting_whenUidlSupported_shouldReturn() + throws Exception { + String response = INITIAL_RESPONSE + + AUTH_HANDLE_RESPONSE + + CAPA_RESPONSE + + AUTH_PLAIN_AUTHENTICATED_RESPONSE + + STAT_RESPONSE + + UIDL_SUPPORTED_RESPONSE; + when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(response.getBytes("UTF-8"))); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + when(mockSocket.getOutputStream()).thenReturn(byteArrayOutputStream); + store.checkSettings(); + } + + // Component Level Tests + @Test public void open_withAuthResponseUsingAuthPlain_shouldRetrieveMessageCountOnAuthenticatedSocket() throws Exception { String response = INITIAL_RESPONSE + @@ -171,7 +247,7 @@ public class Pop3StoreTest { when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(response.getBytes("UTF-8"))); ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); when(mockSocket.getOutputStream()).thenReturn(byteArrayOutputStream); - Folder folder = store.getFolder("Inbox"); + Pop3Folder folder = store.getFolder("Inbox"); folder.open(Folder.OPEN_MODE_RW); @@ -186,7 +262,7 @@ public class Pop3StoreTest { CAPA_RESPONSE + AUTH_PLAIN_FAILED_RESPONSE; when(mockSocket.getInputStream()).thenReturn(new ByteArrayInputStream(response.getBytes("UTF-8"))); - Folder folder = store.getFolder("Inbox"); + Pop3Folder folder = store.getFolder("Inbox"); folder.open(Folder.OPEN_MODE_RW); } diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/SimplePop3Settings.java b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/SimplePop3Settings.java new file mode 100644 index 000000000..37edc46d9 --- /dev/null +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/store/pop3/SimplePop3Settings.java @@ -0,0 +1,72 @@ +package com.fsck.k9.mail.store.pop3; + +import com.fsck.k9.mail.AuthType; +import com.fsck.k9.mail.ConnectionSecurity; + +class SimplePop3Settings implements Pop3Settings { + private String host; + private int port; + private ConnectionSecurity connectionSecurity = ConnectionSecurity.NONE; + private AuthType authType; + private String username; + private String password; + + @Override + public String getHost() { + return host; + } + + @Override + public int getPort() { + return port; + } + + @Override + public ConnectionSecurity getConnectionSecurity() { + return connectionSecurity; + } + + @Override + public AuthType getAuthType() { + return authType; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getClientCertificateAlias() { + return null; + } + + void setHost(String host) { + this.host = host; + } + + void setPort(int port) { + this.port = port; + } + + void setConnectionSecurity(ConnectionSecurity connectionSecurity) { + this.connectionSecurity = connectionSecurity; + } + + void setAuthType(AuthType authType) { + this.authType = authType; + } + + void setUsername(String username) { + this.username = username; + } + + void setPassword(String password) { + this.password = password; + } +}