Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
20
mail/protocols/pop3/build.gradle.kts
Normal file
20
mail/protocols/pop3/build.gradle.kts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
val testCoverageEnabled: Boolean by extra
|
||||
if (testCoverageEnabled) {
|
||||
apply(plugin = "jacoco")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.mail.common)
|
||||
implementation(projects.core.common)
|
||||
|
||||
testImplementation(projects.core.logging.testing)
|
||||
testImplementation(projects.mail.testing)
|
||||
testImplementation(libs.okio)
|
||||
testImplementation(libs.jzlib)
|
||||
testImplementation(libs.commons.io)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.fsck.k9.mail.store.pop3
|
||||
|
||||
internal object Pop3Commands {
|
||||
const val STLS_COMMAND = "STLS"
|
||||
const val USER_COMMAND = "USER"
|
||||
const val PASS_COMMAND = "PASS"
|
||||
const val CAPA_COMMAND = "CAPA"
|
||||
const val AUTH_COMMAND = "AUTH"
|
||||
const val STAT_COMMAND = "STAT"
|
||||
const val LIST_COMMAND = "LIST"
|
||||
const val UIDL_COMMAND = "UIDL"
|
||||
const val TOP_COMMAND = "TOP"
|
||||
const val RETR_COMMAND = "RETR"
|
||||
const val DELE_COMMAND = "DELE"
|
||||
const val QUIT_COMMAND = "QUIT"
|
||||
|
||||
const val STLS_CAPABILITY = "STLS"
|
||||
const val UIDL_CAPABILITY = "UIDL"
|
||||
const val TOP_CAPABILITY = "TOP"
|
||||
const val SASL_CAPABILITY = "SASL"
|
||||
const val AUTH_PLAIN_CAPABILITY = "PLAIN"
|
||||
const val AUTH_CRAM_MD5_CAPABILITY = "CRAM-MD5"
|
||||
const val AUTH_EXTERNAL_CAPABILITY = "EXTERNAL"
|
||||
}
|
||||
|
|
@ -0,0 +1,434 @@
|
|||
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.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.UnknownHostException;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
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 net.thunderbird.core.common.exception.MessagingException;
|
||||
import com.fsck.k9.mail.MissingCapabilityException;
|
||||
import com.fsck.k9.mail.filter.Base64;
|
||||
import com.fsck.k9.mail.filter.Hex;
|
||||
import com.fsck.k9.mail.ssl.CertificateChainExtractor;
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
|
||||
import javax.net.ssl.SSLException;
|
||||
|
||||
import static com.fsck.k9.mail.K9MailLib.DEBUG_PROTOCOL_POP3;
|
||||
import static com.fsck.k9.mail.NetworkTimeouts.SOCKET_CONNECT_TIMEOUT;
|
||||
import static com.fsck.k9.mail.NetworkTimeouts.SOCKET_READ_TIMEOUT;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.AUTH_CRAM_MD5_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.AUTH_EXTERNAL_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.AUTH_PLAIN_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.CAPA_COMMAND;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.PASS_COMMAND;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.SASL_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.STLS_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.STLS_COMMAND;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.TOP_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.UIDL_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.USER_COMMAND;
|
||||
|
||||
|
||||
class Pop3Connection {
|
||||
|
||||
private final Pop3Settings settings;
|
||||
private final TrustedSocketFactory trustedSocketFactory;
|
||||
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) {
|
||||
this.settings = settings;
|
||||
this.trustedSocketFactory = trustedSocketFactory;
|
||||
}
|
||||
|
||||
void open() throws MessagingException {
|
||||
try {
|
||||
socket = connect();
|
||||
in = new BufferedInputStream(socket.getInputStream(), 1024);
|
||||
out = new BufferedOutputStream(socket.getOutputStream(), 512);
|
||||
|
||||
socket.setSoTimeout(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) {
|
||||
List<X509Certificate> certificateChain = CertificateChainExtractor.extract(e);
|
||||
if (certificateChain != null) {
|
||||
throw new CertificateValidationException(certificateChain, 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) {
|
||||
close();
|
||||
throw new MessagingException("Unable to open connection to POP server.", ioe);
|
||||
}
|
||||
}
|
||||
|
||||
private Socket connect()
|
||||
throws IOException, MessagingException, NoSuchAlgorithmException, KeyManagementException {
|
||||
InetAddress[] inetAddresses = InetAddress.getAllByName(settings.getHost());
|
||||
|
||||
IOException connectException = null;
|
||||
for (InetAddress address : inetAddresses) {
|
||||
try {
|
||||
return connectToAddress(address);
|
||||
} catch (IOException e) {
|
||||
Log.w(e, "Could not connect to %s", address);
|
||||
connectException = e;
|
||||
}
|
||||
}
|
||||
|
||||
throw connectException != null ? connectException : new UnknownHostException();
|
||||
}
|
||||
|
||||
private Socket connectToAddress(InetAddress address)
|
||||
throws IOException, MessagingException, NoSuchAlgorithmException, KeyManagementException {
|
||||
if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_POP3) {
|
||||
Log.d("Connecting to %s as %s", settings.getHost(), address);
|
||||
}
|
||||
|
||||
InetSocketAddress socketAddress = new InetSocketAddress(address, settings.getPort());
|
||||
|
||||
final Socket socket;
|
||||
if (settings.getConnectionSecurity() == ConnectionSecurity.SSL_TLS_REQUIRED) {
|
||||
socket = trustedSocketFactory.createSocket(null, settings.getHost(), settings.getPort(),
|
||||
settings.getClientCertificateAlias());
|
||||
} else {
|
||||
socket = new Socket();
|
||||
}
|
||||
|
||||
socket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT);
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
/*
|
||||
* 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(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 MissingCapabilityException(STLS_CAPABILITY);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void performAuthentication(AuthType authType, String serverGreeting)
|
||||
throws MessagingException, IOException {
|
||||
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 {
|
||||
throw new MissingCapabilityException(SASL_CAPABILITY + " " + AUTH_EXTERNAL_CAPABILITY);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new MessagingException(
|
||||
"Unhandled authentication method: "+authType+" 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 {
|
||||
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<String> 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 (saslAuthMechanisms.contains(AUTH_EXTERNAL_CAPABILITY)) {
|
||||
capabilities.external = 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, IOException {
|
||||
executeSimpleCommand(USER_COMMAND + " " + settings.getUsername());
|
||||
try {
|
||||
executeSimpleCommand(PASS_COMMAND + " " + settings.getPassword(), true);
|
||||
} catch (Pop3ErrorResponse e) {
|
||||
throw new AuthenticationFailedException("USER/PASS failed", e, e.getResponseText());
|
||||
}
|
||||
}
|
||||
|
||||
private void authPlain() throws MessagingException, IOException {
|
||||
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("AUTH PLAIN failed", e, e.getResponseText());
|
||||
}
|
||||
}
|
||||
|
||||
private void authAPOP(String serverGreeting) throws MessagingException, IOException {
|
||||
// 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("APOP failed", e, e.getResponseText());
|
||||
}
|
||||
}
|
||||
|
||||
private void authCramMD5() throws MessagingException, IOException {
|
||||
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("AUTH CRAM-MD5 failed", e, e.getResponseText());
|
||||
}
|
||||
}
|
||||
|
||||
private void authExternal() throws MessagingException, IOException {
|
||||
try {
|
||||
executeSimpleCommand(
|
||||
String.format("AUTH EXTERNAL %s",
|
||||
Base64.encode(settings.getUsername())), false);
|
||||
} catch (Pop3ErrorResponse e) {
|
||||
throw new AuthenticationFailedException("AUTH EXTERNAL failed", e, e.getResponseText());
|
||||
}
|
||||
}
|
||||
|
||||
private void writeLine(String s) throws IOException {
|
||||
out.write(s.getBytes());
|
||||
out.write('\r');
|
||||
out.write('\n');
|
||||
out.flush();
|
||||
}
|
||||
|
||||
String executeSimpleCommand(String command) throws IOException, Pop3ErrorResponse {
|
||||
return executeSimpleCommand(command, false);
|
||||
}
|
||||
|
||||
private String executeSimpleCommand(String command, boolean sensitive) throws IOException, Pop3ErrorResponse {
|
||||
if (command != null) {
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) {
|
||||
if (sensitive && !K9MailLib.isDebugSensitive()) {
|
||||
Log.d(">>> [Command Hidden, Enable Sensitive Debug Logging To Show]");
|
||||
} else {
|
||||
Log.d(">>> %s", command);
|
||||
}
|
||||
}
|
||||
|
||||
writeLine(command);
|
||||
}
|
||||
|
||||
String response = readLine();
|
||||
if (response.length() == 0 || response.charAt(0) != '+') {
|
||||
throw new Pop3ErrorResponse(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
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) {
|
||||
Log.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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.fsck.k9.mail.store.pop3;
|
||||
|
||||
|
||||
import net.thunderbird.core.common.exception.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);
|
||||
}
|
||||
|
||||
public String getResponseText() {
|
||||
// TODO: Extract response text from response line
|
||||
return getMessage();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,551 @@
|
|||
package com.fsck.k9.mail.store.pop3;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
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 net.thunderbird.core.logging.legacy.Log;
|
||||
import com.fsck.k9.mail.FetchProfile;
|
||||
import com.fsck.k9.mail.Flag;
|
||||
import com.fsck.k9.mail.K9MailLib;
|
||||
import com.fsck.k9.mail.MessageRetrievalListener;
|
||||
import net.thunderbird.core.common.exception.MessagingException;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
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.
|
||||
*/
|
||||
public class Pop3Folder {
|
||||
public static final String INBOX = "INBOX";
|
||||
|
||||
|
||||
private Pop3Store pop3Store;
|
||||
private Map<String, Pop3Message> uidToMsgMap = new HashMap<>();
|
||||
private Map<Integer, Pop3Message> msgNumToMsgMap = new HashMap<>();
|
||||
private Map<String, Integer> uidToMsgNumMap = new HashMap<>();
|
||||
private String name;
|
||||
private int messageCount;
|
||||
private Pop3Connection connection;
|
||||
|
||||
Pop3Folder(Pop3Store pop3Store, String name) {
|
||||
super();
|
||||
this.pop3Store = pop3Store;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public synchronized void open() throws MessagingException {
|
||||
if (isOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!INBOX.equals(name)) {
|
||||
throw new MessagingException("Folder does not exist");
|
||||
}
|
||||
|
||||
connection = pop3Store.createConnection();
|
||||
try {
|
||||
connection.open();
|
||||
|
||||
String response = connection.executeSimpleCommand(STAT_COMMAND);
|
||||
String[] parts = response.split(" ");
|
||||
messageCount = Integer.parseInt(parts[1]);
|
||||
|
||||
uidToMsgMap.clear();
|
||||
msgNumToMsgMap.clear();
|
||||
uidToMsgNumMap.clear();
|
||||
} catch (IOException e) {
|
||||
handleIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isOpen() {
|
||||
return connection != null && connection.isOpen();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public String getServerId() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public int getMessageCount() {
|
||||
return messageCount;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public Pop3Message getMessage(String uid) {
|
||||
Pop3Message message = uidToMsgMap.get(uid);
|
||||
if (message == null) {
|
||||
message = new Pop3Message(uid);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
public List<Pop3Message> getMessages(int start, int end, MessageRetrievalListener<Pop3Message> 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) {
|
||||
handleIOException(ioe);
|
||||
}
|
||||
List<Pop3Message> messages = new ArrayList<>();
|
||||
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;
|
||||
}
|
||||
|
||||
messages.add(message);
|
||||
if (listener != null) {
|
||||
listener.messageFinished(message);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
public boolean areMoreMessagesAvailable(int indexOfOldestMessage) {
|
||||
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])) {
|
||||
Log.e("ERR response: %s", response);
|
||||
return;
|
||||
}
|
||||
String msgUid = uidParts[2];
|
||||
message = new Pop3Message(msgUid);
|
||||
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);
|
||||
indexMessage(msgNum, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void indexUids(List<String> uids)
|
||||
throws MessagingException, IOException {
|
||||
Set<String> unindexedUids = new HashSet<>();
|
||||
for (String uid : uids) {
|
||||
if (uidToMsgMap.get(uid) == null) {
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) {
|
||||
Log.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) {
|
||||
Log.d("Got msgNum %d for UID %s", msgNum, msgUid);
|
||||
}
|
||||
|
||||
Pop3Message message = uidToMsgMap.get(msgUid);
|
||||
if (message == null) {
|
||||
message = new Pop3Message(msgUid);
|
||||
}
|
||||
indexMessage(msgNum, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void indexMessage(int msgNum, Pop3Message message) {
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) {
|
||||
Log.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
|
||||
*/
|
||||
public void fetch(List<Pop3Message> messages, FetchProfile fp,
|
||||
MessageRetrievalListener<Pop3Message> listener, int maxDownloadSize)
|
||||
throws MessagingException {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<String> uids = new ArrayList<>();
|
||||
for (Pop3Message message : messages) {
|
||||
uids.add(message.getUid());
|
||||
}
|
||||
try {
|
||||
indexUids(uids);
|
||||
} catch (IOException ioe) {
|
||||
handleIOException(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) {
|
||||
handleIOException(ioe);
|
||||
}
|
||||
for (Pop3Message pop3Message : messages) {
|
||||
try {
|
||||
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 (maxDownloadSize > 0) {
|
||||
fetchBody(pop3Message, maxDownloadSize / 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);
|
||||
}
|
||||
} catch (IOException ioe) {
|
||||
handleIOException(ioe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void fetchEnvelope(List<Pop3Message> messages,
|
||||
MessageRetrievalListener<Pop3Message> listener) throws IOException, MessagingException {
|
||||
int unsizedMessages = 0;
|
||||
for (Pop3Message 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 (Pop3Message message : messages) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Set<String> msgUidIndex = new HashSet<>();
|
||||
for (Pop3Message 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())) {
|
||||
pop3Message.setSize(msgSize);
|
||||
if (listener != null) {
|
||||
listener.messageFinished(pop3Message);
|
||||
}
|
||||
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()) {
|
||||
Log.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) {
|
||||
Log.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setFlags(List<Pop3Message> messages, final Set<Flag> flags, boolean value)
|
||||
throws MessagingException {
|
||||
if (!value || !flags.contains(Flag.DELETED)) {
|
||||
/*
|
||||
* The only flagging we support is setting the Deleted flag.
|
||||
*/
|
||||
return;
|
||||
}
|
||||
List<String> uids = new ArrayList<>();
|
||||
try {
|
||||
for (Pop3Message message : messages) {
|
||||
uids.add(message.getUid());
|
||||
}
|
||||
|
||||
indexUids(uids);
|
||||
} catch (IOException ioe) {
|
||||
handleIOException(ioe);
|
||||
}
|
||||
for (Pop3Message message : messages) {
|
||||
|
||||
Integer msgNum = uidToMsgNumMap.get(message.getUid());
|
||||
if (msgNum == null) {
|
||||
throw new MessagingException(
|
||||
"Could not delete message " + message.getUid() + " because no msgNum found; permanent error",
|
||||
true
|
||||
);
|
||||
}
|
||||
open();
|
||||
try {
|
||||
connection.executeSimpleCommand(String.format(DELE_COMMAND + " %s", msgNum));
|
||||
} catch (IOException e) {
|
||||
handleIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isFlagSupported(Flag flag) {
|
||||
return (flag == Flag.DELETED);
|
||||
}
|
||||
|
||||
@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 {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
handleIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleIOException(IOException exception) throws MessagingException {
|
||||
Pop3Connection connection = this.connection;
|
||||
if (connection != null) {
|
||||
connection.close();
|
||||
}
|
||||
|
||||
// For now we wrap IOExceptions in a MessagingException
|
||||
throw new MessagingException("I/O error", exception);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.fsck.k9.mail.store.pop3;
|
||||
|
||||
|
||||
import com.fsck.k9.mail.internet.MimeMessage;
|
||||
|
||||
|
||||
public class Pop3Message extends MimeMessage {
|
||||
Pop3Message(String uid) {
|
||||
mUid = uid;
|
||||
mSize = -1;
|
||||
}
|
||||
|
||||
public void setSize(int size) {
|
||||
mSize = size;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.fsck.k9.mail.store.pop3
|
||||
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.CertificateValidationException
|
||||
import com.fsck.k9.mail.ClientCertificateError.CertificateExpired
|
||||
import com.fsck.k9.mail.ClientCertificateError.RetrievalFailure
|
||||
import com.fsck.k9.mail.ClientCertificateException
|
||||
import com.fsck.k9.mail.MissingCapabilityException
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.oauth.AuthStateStorage
|
||||
import com.fsck.k9.mail.server.ServerSettingsValidationResult
|
||||
import com.fsck.k9.mail.server.ServerSettingsValidationResult.ClientCertificateError
|
||||
import com.fsck.k9.mail.server.ServerSettingsValidator
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import java.io.IOException
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
|
||||
class Pop3ServerSettingsValidator(
|
||||
private val trustedSocketFactory: TrustedSocketFactory,
|
||||
) : ServerSettingsValidator {
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun checkServerSettings(
|
||||
serverSettings: ServerSettings,
|
||||
authStateStorage: AuthStateStorage?,
|
||||
): ServerSettingsValidationResult {
|
||||
val store = Pop3Store(serverSettings, trustedSocketFactory)
|
||||
|
||||
return try {
|
||||
store.checkSettings()
|
||||
|
||||
ServerSettingsValidationResult.Success
|
||||
} catch (e: AuthenticationFailedException) {
|
||||
ServerSettingsValidationResult.AuthenticationError(e.messageFromServer)
|
||||
} catch (e: CertificateValidationException) {
|
||||
ServerSettingsValidationResult.CertificateError(e.certificateChain)
|
||||
} catch (e: Pop3ErrorResponse) {
|
||||
ServerSettingsValidationResult.ServerError(e.responseText)
|
||||
} catch (e: MissingCapabilityException) {
|
||||
ServerSettingsValidationResult.MissingServerCapabilityError(e.capabilityName)
|
||||
} catch (e: ClientCertificateException) {
|
||||
when (e.error) {
|
||||
RetrievalFailure -> ClientCertificateError.ClientCertificateRetrievalFailure
|
||||
CertificateExpired -> ClientCertificateError.ClientCertificateExpired
|
||||
}
|
||||
} catch (e: MessagingException) {
|
||||
val cause = e.cause
|
||||
if (cause is IOException) {
|
||||
ServerSettingsValidationResult.NetworkError(cause)
|
||||
} else {
|
||||
ServerSettingsValidationResult.UnknownError(e)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
ServerSettingsValidationResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
ServerSettingsValidationResult.UnknownError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package com.fsck.k9.mail.store.pop3;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
import com.fsck.k9.mail.AuthType;
|
||||
import com.fsck.k9.mail.ConnectionSecurity;
|
||||
import net.thunderbird.core.common.exception.MessagingException;
|
||||
import com.fsck.k9.mail.ServerSettings;
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
|
||||
public class Pop3Store {
|
||||
private final TrustedSocketFactory trustedSocketFactory;
|
||||
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<String, Pop3Folder> mFolders = new HashMap<>();
|
||||
|
||||
public Pop3Store(ServerSettings serverSettings, TrustedSocketFactory socketFactory) {
|
||||
if (!serverSettings.type.equals("pop3")) {
|
||||
throw new IllegalArgumentException("Expected POP3 ServerSettings");
|
||||
}
|
||||
|
||||
trustedSocketFactory = socketFactory;
|
||||
host = serverSettings.host;
|
||||
port = serverSettings.port;
|
||||
connectionSecurity = serverSettings.connectionSecurity;
|
||||
username = serverSettings.username;
|
||||
password = serverSettings.password;
|
||||
clientCertificateAlias = serverSettings.clientCertificateAlias;
|
||||
authType = serverSettings.authenticationType;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public Pop3Folder getFolder(String name) {
|
||||
Pop3Folder folder = mFolders.get(name);
|
||||
if (folder == null) {
|
||||
folder = new Pop3Folder(this, name);
|
||||
mFolders.put(folder.getServerId(), folder);
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
public void checkSettings() throws MessagingException {
|
||||
Pop3Folder folder = new Pop3Folder(this, Pop3Folder.INBOX);
|
||||
try {
|
||||
folder.open();
|
||||
folder.requestUidl();
|
||||
} catch (Exception e) {
|
||||
Log.e(e, "Error while checking server settings");
|
||||
throw e;
|
||||
} finally {
|
||||
folder.close();
|
||||
}
|
||||
}
|
||||
|
||||
public Pop3Connection createConnection() throws MessagingException {
|
||||
return new Pop3Connection(new StorePop3Settings(), trustedSocketFactory);
|
||||
}
|
||||
|
||||
private class StorePop3Settings implements Pop3Settings {
|
||||
@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 clientCertificateAlias;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
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 com.fsck.k9.mail.testing.security.TestKeyStoreProvider;
|
||||
import com.jcraft.jzlib.JZlib;
|
||||
import com.jcraft.jzlib.ZOutputStream;
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import okio.BufferedSink;
|
||||
import okio.BufferedSource;
|
||||
import okio.Okio;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
|
||||
|
||||
public class MockPop3Server {
|
||||
private static final byte[] CRLF = { '\r', '\n' };
|
||||
|
||||
|
||||
private final Deque<ImapInteraction> interactions = new ConcurrentLinkedDeque<>();
|
||||
private final CountDownLatch waitForConnectionClosed = new CountDownLatch(1);
|
||||
private final CountDownLatch waitForAllExpectedCommands = new CountDownLatch(1);
|
||||
private final TestKeyStoreProvider keyStoreProvider;
|
||||
private final Logger logger;
|
||||
|
||||
private MockServerThread mockServerThread;
|
||||
private String host;
|
||||
private int port;
|
||||
|
||||
|
||||
public MockPop3Server() {
|
||||
this(TestKeyStoreProvider.INSTANCE, new DefaultLogger());
|
||||
}
|
||||
|
||||
public MockPop3Server(TestKeyStoreProvider keyStoreProvider, Logger logger) {
|
||||
this.keyStoreProvider = keyStoreProvider;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public void output(String response) {
|
||||
checkServerNotRunning();
|
||||
interactions.add(new CannedResponse(response));
|
||||
}
|
||||
|
||||
public void expect(String command) {
|
||||
checkServerNotRunning();
|
||||
interactions.add(new ExpectedCommand(command));
|
||||
}
|
||||
|
||||
public void startTls() {
|
||||
checkServerNotRunning();
|
||||
interactions.add(new UpgradeToTls());
|
||||
}
|
||||
|
||||
public void 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<ImapInteraction> interactions;
|
||||
private final CountDownLatch waitForConnectionClosed;
|
||||
private final CountDownLatch waitForAllExpectedCommands;
|
||||
private final Logger logger;
|
||||
private final TestKeyStoreProvider keyStoreProvider;
|
||||
|
||||
private volatile boolean shouldStop = false;
|
||||
private volatile Socket clientSocket;
|
||||
|
||||
private BufferedSource input;
|
||||
private BufferedSink output;
|
||||
private volatile UnexpectedCommandException unexpectedCommandException;
|
||||
|
||||
|
||||
public MockServerThread(ServerSocket serverSocket, Deque<ImapInteraction> interactions,
|
||||
CountDownLatch waitForConnectionClosed, CountDownLatch waitForAllExpectedCommands, Logger logger,
|
||||
TestKeyStoreProvider 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
package com.fsck.k9.mail.store.pop3
|
||||
|
||||
import assertk.assertFailure
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.prop
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.AuthType.CRAM_MD5
|
||||
import com.fsck.k9.mail.AuthType.EXTERNAL
|
||||
import com.fsck.k9.mail.AuthType.PLAIN
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.CertificateChainException
|
||||
import com.fsck.k9.mail.CertificateValidationException
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import com.fsck.k9.mail.ConnectionSecurity.NONE
|
||||
import com.fsck.k9.mail.ConnectionSecurity.SSL_TLS_REQUIRED
|
||||
import com.fsck.k9.mail.ConnectionSecurity.STARTTLS_REQUIRED
|
||||
import com.fsck.k9.mail.MissingCapabilityException
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import com.fsck.k9.mail.testing.security.TestTrustedSocketFactory
|
||||
import java.io.IOException
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import javax.net.ssl.SSLException
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.logging.testing.TestLogger
|
||||
import okio.ByteString.Companion.encodeUtf8
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.doThrow
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.verifyNoInteractions
|
||||
|
||||
class Pop3ConnectionTest {
|
||||
private val socketFactory = TestTrustedSocketFactory
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Log.logger = TestLogger()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when TrustedSocketFactory throws wrapped CertificateChainException, open() should throw`() {
|
||||
val server = startTlsServer()
|
||||
val settings = server.createSettings(connectionSecurity = SSL_TLS_REQUIRED)
|
||||
val mockSocketFactory = mock<TrustedSocketFactory> {
|
||||
on { createSocket(null, settings.host, settings.port, null) } doThrow
|
||||
SSLException(CertificateChainException("irrelevant", arrayOf(), null))
|
||||
}
|
||||
|
||||
assertFailure {
|
||||
createAndOpenPop3Connection(settings, mockSocketFactory)
|
||||
}.isInstanceOf<CertificateValidationException>()
|
||||
}
|
||||
|
||||
@Test(expected = MessagingException::class)
|
||||
fun `when TrustedSocketFactory throws CertificateException, open() should throw MessagingException`() {
|
||||
val server = startTlsServer()
|
||||
val settings = server.createSettings(connectionSecurity = SSL_TLS_REQUIRED)
|
||||
val mockSocketFactory = mock<TrustedSocketFactory> {
|
||||
on { createSocket(null, settings.host, settings.port, null) } doThrow SSLException("")
|
||||
}
|
||||
|
||||
createAndOpenPop3Connection(settings, mockSocketFactory)
|
||||
}
|
||||
|
||||
@Test(expected = MessagingException::class)
|
||||
fun `when TrustedSocketFactory throws NoSuchAlgorithmException, open() should throw MessagingException`() {
|
||||
val server = startTlsServer()
|
||||
val settings = server.createSettings(connectionSecurity = SSL_TLS_REQUIRED)
|
||||
val mockSocketFactory = mock<TrustedSocketFactory> {
|
||||
on { createSocket(null, settings.host, settings.port, null) } doThrow NoSuchAlgorithmException()
|
||||
}
|
||||
|
||||
createAndOpenPop3Connection(settings, mockSocketFactory)
|
||||
}
|
||||
|
||||
@Test(expected = MessagingException::class)
|
||||
fun `when TrustedSocketFactory throws IOException, open() should throw MessagingException`() {
|
||||
val server = startTlsServer()
|
||||
val settings = server.createSettings(connectionSecurity = SSL_TLS_REQUIRED)
|
||||
val mockSocketFactory = mock<TrustedSocketFactory> {
|
||||
on { createSocket(null, settings.host, settings.port, null) } doThrow IOException()
|
||||
}
|
||||
|
||||
createAndOpenPop3Connection(settings, mockSocketFactory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with STLS capability unavailable should throw`() {
|
||||
val server = startServer {
|
||||
setupServerWithAuthenticationMethods("PLAIN")
|
||||
}
|
||||
val settings = server.createSettings(connectionSecurity = STARTTLS_REQUIRED)
|
||||
|
||||
assertFailure {
|
||||
createAndOpenPop3Connection(settings)
|
||||
}.isInstanceOf<MissingCapabilityException>()
|
||||
.prop(MissingCapabilityException::capabilityName).isEqualTo("STLS")
|
||||
}
|
||||
|
||||
@Test(expected = Pop3ErrorResponse::class)
|
||||
fun `open() with error response to STLS command should throw`() {
|
||||
val server = startServer {
|
||||
setupServerWithStartTlsAvailable()
|
||||
expect("STLS")
|
||||
output("-ERR Unavailable")
|
||||
}
|
||||
val settings = server.createSettings(connectionSecurity = STARTTLS_REQUIRED)
|
||||
|
||||
createAndOpenPop3Connection(settings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with STLS error response should not call createSocket() to upgrade to TLS`() {
|
||||
val server = startServer {
|
||||
setupServerWithStartTlsAvailable()
|
||||
expect("STLS")
|
||||
output("-ERR Unavailable")
|
||||
}
|
||||
val settings = server.createSettings(connectionSecurity = STARTTLS_REQUIRED)
|
||||
val mockSocketFactory = mock<TrustedSocketFactory>()
|
||||
|
||||
try {
|
||||
createAndOpenPop3Connection(settings, mockSocketFactory)
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
|
||||
verifyNoInteractions(mockSocketFactory)
|
||||
}
|
||||
|
||||
@Test(expected = MessagingException::class)
|
||||
fun `open() with StartTLS and TrustedSocketFactory throwing should throw`() {
|
||||
val server = startServer {
|
||||
setupServerWithStartTlsAvailable()
|
||||
expect("STLS")
|
||||
output("+OK Begin TLS negotiation")
|
||||
}
|
||||
val settings = server.createSettings(connectionSecurity = STARTTLS_REQUIRED)
|
||||
val mockSocketFactory = mock<TrustedSocketFactory> {
|
||||
on { createSocket(any(), eq(settings.host), eq(settings.port), eq(null)) } doThrow IOException()
|
||||
}
|
||||
|
||||
createAndOpenPop3Connection(settings, mockSocketFactory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with AUTH PLAIN`() {
|
||||
val server = startServer {
|
||||
setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL")
|
||||
expect("AUTH PLAIN")
|
||||
output("+OK")
|
||||
expect(AUTH_PLAIN_ARGUMENT)
|
||||
output("+OK")
|
||||
}
|
||||
val settings = server.createSettings(authType = PLAIN)
|
||||
|
||||
createAndOpenPop3Connection(settings)
|
||||
|
||||
server.verifyConnectionStillOpen()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with authentication error should throw`() {
|
||||
val server = startServer {
|
||||
setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL")
|
||||
expect("AUTH PLAIN")
|
||||
output("+OK")
|
||||
expect(AUTH_PLAIN_ARGUMENT)
|
||||
output("-ERR")
|
||||
}
|
||||
val settings = server.createSettings(authType = PLAIN)
|
||||
|
||||
assertFailure {
|
||||
createAndOpenPop3Connection(settings)
|
||||
}.isInstanceOf<AuthenticationFailedException>()
|
||||
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with AuthType_PLAIN and no SASL PLAIN capability should use USER and PASS commands`() {
|
||||
val server = startServer {
|
||||
setupServerWithAuthenticationMethods("CRAM-MD5 EXTERNAL")
|
||||
expect("USER user")
|
||||
output("+OK")
|
||||
expect("PASS password")
|
||||
output("+OK")
|
||||
}
|
||||
val settings = server.createSettings(authType = PLAIN)
|
||||
|
||||
createAndOpenPop3Connection(settings)
|
||||
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with authentication failure during fallback to USER and PASS commands should throw`() {
|
||||
val server = startServer {
|
||||
setupServerWithAuthenticationMethods("CRAM-MD5 EXTERNAL")
|
||||
expect("USER user")
|
||||
output("+OK")
|
||||
expect("PASS password")
|
||||
output("-ERR")
|
||||
}
|
||||
val settings = server.createSettings(authType = PLAIN)
|
||||
|
||||
assertFailure {
|
||||
createAndOpenPop3Connection(settings)
|
||||
}.isInstanceOf<AuthenticationFailedException>()
|
||||
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with CRAM-MD5 authentication`() {
|
||||
val server = startServer {
|
||||
setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL")
|
||||
expect("AUTH CRAM-MD5")
|
||||
output("+ abcd")
|
||||
expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg==")
|
||||
output("+OK")
|
||||
}
|
||||
val settings = server.createSettings(authType = CRAM_MD5)
|
||||
|
||||
createAndOpenPop3Connection(settings)
|
||||
|
||||
server.verifyConnectionStillOpen()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with authentication failure when using CRAM-MD5 should throw`() {
|
||||
val server = startServer {
|
||||
setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL")
|
||||
expect("AUTH CRAM-MD5")
|
||||
output("+ abcd")
|
||||
expect("dXNlciBhZGFhZTU2Zjk1NzAxZjQwNDQwZjhhMWU2YzY1ZjZmZg==")
|
||||
output("-ERR")
|
||||
}
|
||||
val settings = server.createSettings(authType = CRAM_MD5)
|
||||
|
||||
assertFailure {
|
||||
createAndOpenPop3Connection(settings)
|
||||
}.isInstanceOf<AuthenticationFailedException>()
|
||||
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with CRAM-MD5 configured but missing capability should use APOP`() {
|
||||
val server = startServer {
|
||||
output("+OK abc<a>abcd")
|
||||
expect("CAPA")
|
||||
output("+OK Listing of supported mechanisms follows")
|
||||
output("SASL PLAIN EXTERNAL")
|
||||
output(".")
|
||||
expect("APOP user c8e8c560e385faaa6367d4145572b8ea")
|
||||
output("+OK")
|
||||
}
|
||||
val settings = server.createSettings(authType = CRAM_MD5)
|
||||
|
||||
createAndOpenPop3Connection(settings)
|
||||
|
||||
server.verifyConnectionStillOpen()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with authentication failure when using APOP should throw`() {
|
||||
val server = startServer {
|
||||
output("+OK abc<a>abcd")
|
||||
expect("CAPA")
|
||||
output("+OK Listing of supported mechanisms follows")
|
||||
output("SASL PLAIN EXTERNAL")
|
||||
output(".")
|
||||
expect("APOP user c8e8c560e385faaa6367d4145572b8ea")
|
||||
output("-ERR")
|
||||
}
|
||||
val settings = server.createSettings(authType = CRAM_MD5)
|
||||
|
||||
assertFailure {
|
||||
createAndOpenPop3Connection(settings)
|
||||
}.isInstanceOf<AuthenticationFailedException>()
|
||||
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with AUTH EXTERNAL`() {
|
||||
val server = startServer {
|
||||
setupServerWithAuthenticationMethods("CRAM-MD5 EXTERNAL")
|
||||
expect("AUTH EXTERNAL dXNlcg==")
|
||||
output("+OK")
|
||||
}
|
||||
val settings = server.createSettings(authType = EXTERNAL)
|
||||
|
||||
createAndOpenPop3Connection(settings)
|
||||
|
||||
server.verifyConnectionStillOpen()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with AuthType_EXTERNAL configured but missing capability should throw`() {
|
||||
val server = startServer {
|
||||
setupServerWithAuthenticationMethods("PLAIN CRAM-MD5")
|
||||
}
|
||||
val settings = server.createSettings(authType = EXTERNAL)
|
||||
|
||||
assertFailure {
|
||||
createAndOpenPop3Connection(settings)
|
||||
}.isInstanceOf<MissingCapabilityException>()
|
||||
.prop(MissingCapabilityException::capabilityName).isEqualTo("SASL EXTERNAL")
|
||||
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with authentication failure when using AUTH EXTERNAL should throw`() {
|
||||
val server = startServer {
|
||||
setupServerWithAuthenticationMethods("PLAIN CRAM-MD5 EXTERNAL")
|
||||
expect("AUTH EXTERNAL dXNlcg==")
|
||||
output("-ERR Invalid certificate")
|
||||
}
|
||||
val settings = server.createSettings(authType = EXTERNAL)
|
||||
|
||||
assertFailure {
|
||||
createAndOpenPop3Connection(settings)
|
||||
}.isInstanceOf<AuthenticationFailedException>()
|
||||
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with StartTLS and AUTH PLAIN`() {
|
||||
val server = startServer {
|
||||
setupServerWithStartTlsAvailable()
|
||||
expect("STLS")
|
||||
output("+OK Begin TLS negotiation")
|
||||
startTls()
|
||||
expect("CAPA")
|
||||
output("+OK Listing of supported mechanisms follows")
|
||||
output("SASL PLAIN")
|
||||
output(".")
|
||||
expect("AUTH PLAIN")
|
||||
output("+OK")
|
||||
expect(AUTH_PLAIN_ARGUMENT)
|
||||
output("+OK")
|
||||
}
|
||||
val settings = server.createSettings(authType = PLAIN, connectionSecurity = STARTTLS_REQUIRED)
|
||||
|
||||
createAndOpenPop3Connection(settings)
|
||||
|
||||
server.verifyConnectionStillOpen()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
private fun createAndOpenPop3Connection(
|
||||
settings: Pop3Settings,
|
||||
trustedSocketFactory: TrustedSocketFactory = socketFactory,
|
||||
) {
|
||||
val connection = Pop3Connection(settings, trustedSocketFactory)
|
||||
connection.open()
|
||||
}
|
||||
|
||||
private fun MockPop3Server.setupServerWithAuthenticationMethods(authenticationMethods: String) {
|
||||
output("+OK POP3 server greeting")
|
||||
expect("CAPA")
|
||||
output("+OK Listing of supported mechanisms follows")
|
||||
output("SASL $authenticationMethods")
|
||||
output(".")
|
||||
}
|
||||
|
||||
private fun MockPop3Server.setupServerWithStartTlsAvailable() {
|
||||
output("+OK POP3 server greeting")
|
||||
expect("CAPA")
|
||||
output("+OK Listing of supported mechanisms follows")
|
||||
output("STLS")
|
||||
output("SASL PLAIN")
|
||||
output(".")
|
||||
}
|
||||
|
||||
private fun startTlsServer(): MockPop3Server {
|
||||
// MockPop3Server doesn't actually support implicit TLS. However, all tests using this method will encounter
|
||||
// an exception before sending the first command to the server.
|
||||
return startServer { }
|
||||
}
|
||||
|
||||
private fun MockPop3Server.createSettings(
|
||||
authType: AuthType = PLAIN,
|
||||
connectionSecurity: ConnectionSecurity = NONE,
|
||||
): Pop3Settings {
|
||||
return SimplePop3Settings().apply {
|
||||
username = USERNAME
|
||||
password = PASSWORD
|
||||
this.authType = authType
|
||||
host = this@createSettings.host
|
||||
port = this@createSettings.port
|
||||
this.connectionSecurity = connectionSecurity
|
||||
}
|
||||
}
|
||||
|
||||
private fun startServer(block: MockPop3Server.() -> Unit): MockPop3Server {
|
||||
return MockPop3Server().apply(block).apply { start() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val USERNAME = "user"
|
||||
private const val PASSWORD = "password"
|
||||
|
||||
private val AUTH_PLAIN_ARGUMENT = "\u0000$USERNAME\u0000$PASSWORD".encodeUtf8().base64()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
package com.fsck.k9.mail.store.pop3
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isSameInstanceAs
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.Body
|
||||
import com.fsck.k9.mail.FetchProfile
|
||||
import com.fsck.k9.mail.MessageRetrievalListener
|
||||
import com.fsck.k9.mail.internet.BinaryTempFileBody
|
||||
import com.fsck.k9.mail.store.pop3.Pop3Commands.STAT_COMMAND
|
||||
import com.fsck.k9.mail.testing.crlf
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.times
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.doThrow
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.stubbing
|
||||
|
||||
class Pop3FolderTest {
|
||||
private val connection = mock<Pop3Connection> {
|
||||
on { executeSimpleCommand(STAT_COMMAND) } doReturn "+OK $MESSAGE_COUNT 0"
|
||||
on { isOpen } doReturn true
|
||||
}
|
||||
private val store = mock<Pop3Store> {
|
||||
on { createConnection() } doReturn connection
|
||||
}
|
||||
private val messageRetrievalListener = mock<MessageRetrievalListener<Pop3Message>>()
|
||||
private val folder = Pop3Folder(store, Pop3Folder.INBOX)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
BinaryTempFileBody.setTempDirectory(File(System.getProperty("java.io.tmpdir")))
|
||||
}
|
||||
|
||||
@Test(expected = MessagingException::class)
|
||||
fun `open() without Inbox folder should throw`() {
|
||||
val folder = Pop3Folder(store, "TestFolder")
|
||||
|
||||
folder.open()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() without Inbox folder should not call Pop3Store_createConnection()`() {
|
||||
val folder = Pop3Folder(store, "TestFolder")
|
||||
|
||||
try {
|
||||
folder.open()
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
|
||||
verify(store, never()).createConnection()
|
||||
}
|
||||
|
||||
@Test(expected = MessagingException::class)
|
||||
fun `open() with exception when creating a connection should throw`() {
|
||||
stubbing(store) {
|
||||
on { createConnection() } doThrow MessagingException("Test")
|
||||
}
|
||||
|
||||
folder.open()
|
||||
}
|
||||
|
||||
@Test(expected = AuthenticationFailedException::class)
|
||||
fun `open() with failed authentication should throw`() {
|
||||
stubbing(connection) {
|
||||
on { open() } doThrow AuthenticationFailedException("Test")
|
||||
}
|
||||
|
||||
folder.open()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() should set message count from STAT response`() {
|
||||
folder.open()
|
||||
|
||||
assertThat(folder.messageCount).isEqualTo(MESSAGE_COUNT)
|
||||
}
|
||||
|
||||
@Test(expected = MessagingException::class)
|
||||
fun `open() with STAT command failing should throw`() {
|
||||
stubbing(connection) {
|
||||
on { executeSimpleCommand(STAT_COMMAND) } doThrow Pop3ErrorResponse("Test")
|
||||
}
|
||||
|
||||
folder.open()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() should open connection`() {
|
||||
folder.open()
|
||||
|
||||
verify(store, times(1)).createConnection()
|
||||
verify(connection).open()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open() with connection already open should not create another connection`() {
|
||||
folder.open()
|
||||
|
||||
folder.open()
|
||||
|
||||
verify(store, times(1)).createConnection()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close() with closed folder should not throw`() {
|
||||
folder.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close() with open folder should not throw`() {
|
||||
folder.open()
|
||||
|
||||
folder.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close() with open folder should send QUIT command`() {
|
||||
folder.open()
|
||||
|
||||
folder.close()
|
||||
|
||||
verify(connection).executeSimpleCommand(Pop3Commands.QUIT_COMMAND)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close() with exception when sending QUIT command should not throw`() {
|
||||
stubbing(connection) {
|
||||
on { executeSimpleCommand(Pop3Commands.QUIT_COMMAND) } doThrow Pop3ErrorResponse("Test")
|
||||
}
|
||||
folder.open()
|
||||
|
||||
folder.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close() with open folder should close connection`() {
|
||||
folder.open()
|
||||
|
||||
folder.close()
|
||||
|
||||
verify(connection).close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMessages() should return list of messages on server`() {
|
||||
stubbing(connection) {
|
||||
on { readLine() } doReturn "1 $MESSAGE_SERVER_ID" doReturn "."
|
||||
}
|
||||
folder.open()
|
||||
|
||||
val result = folder.getMessages(1, 1, messageRetrievalListener)
|
||||
|
||||
assertThat(result).hasSize(1)
|
||||
}
|
||||
|
||||
@Test(expected = MessagingException::class)
|
||||
fun `getMessages() with invalid set should throw`() {
|
||||
folder.open()
|
||||
|
||||
folder.getMessages(2, 1, messageRetrievalListener)
|
||||
}
|
||||
|
||||
@Test(expected = MessagingException::class)
|
||||
fun `getMessages() with IOException when reading line should throw`() {
|
||||
stubbing(connection) {
|
||||
on { readLine() } doThrow IOException("Test")
|
||||
}
|
||||
folder.open()
|
||||
|
||||
folder.getMessages(1, 1, messageRetrievalListener)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMessage() with previously fetched message should return message`() {
|
||||
folder.open()
|
||||
val messageList = setupMessageFromServer()
|
||||
|
||||
val message = folder.getMessage(MESSAGE_SERVER_ID)
|
||||
|
||||
assertThat(message).isSameInstanceAs(messageList.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMessage() without previously fetched message should return new message`() {
|
||||
folder.open()
|
||||
|
||||
val message = folder.getMessage(MESSAGE_SERVER_ID)
|
||||
|
||||
assertThat(message).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch() with ENVELOPE profile should set size of message`() {
|
||||
folder.open()
|
||||
val messageList = setupMessageFromServer()
|
||||
val fetchProfile = FetchProfile()
|
||||
fetchProfile.add(FetchProfile.Item.ENVELOPE)
|
||||
stubbing(connection) {
|
||||
on { readLine() } doReturn "1 100" doReturn "."
|
||||
}
|
||||
|
||||
folder.fetch(messageList, fetchProfile, messageRetrievalListener, MAX_DOWNLOAD_SIZE)
|
||||
|
||||
assertThat(messageList.first().size).isEqualTo(100)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch() with BODY profile should set content of message`() {
|
||||
val messageInputStream =
|
||||
"""
|
||||
From: <adam@example.org>
|
||||
To: <eva@example.org>
|
||||
Subject: Testmail
|
||||
MIME-Version: 1.0
|
||||
Content-type: text/plain
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
this is some test text.
|
||||
""".trimIndent().crlf().byteInputStream()
|
||||
folder.open()
|
||||
val messageList = setupMessageFromServer()
|
||||
val fetchProfile = FetchProfile()
|
||||
fetchProfile.add(FetchProfile.Item.BODY)
|
||||
stubbing(connection) {
|
||||
on { readLine() } doReturn "1 100" doReturn "."
|
||||
on { inputStream } doReturn messageInputStream
|
||||
}
|
||||
|
||||
folder.fetch(messageList, fetchProfile, messageRetrievalListener, MAX_DOWNLOAD_SIZE)
|
||||
|
||||
assertThat(messageList.first().body.writeToString()).isEqualTo("this is some test text.")
|
||||
}
|
||||
|
||||
private fun setupMessageFromServer(): List<Pop3Message> {
|
||||
stubbing(connection) {
|
||||
on { readLine() } doReturn "1 $MESSAGE_SERVER_ID" doReturn "."
|
||||
}
|
||||
|
||||
return folder.getMessages(1, 1, messageRetrievalListener)
|
||||
}
|
||||
|
||||
private fun Body.writeToString(): String {
|
||||
return ByteArrayOutputStream().also { outputStream ->
|
||||
writeTo(outputStream)
|
||||
}.toByteArray().decodeToString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MESSAGE_COUNT = 10
|
||||
private const val MESSAGE_SERVER_ID = "abcd"
|
||||
private const val MAX_DOWNLOAD_SIZE = -1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
package com.fsck.k9.mail.store.pop3
|
||||
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.prop
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ClientCertificateError
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.server.ServerSettingsValidationResult
|
||||
import com.fsck.k9.mail.testing.security.FakeTrustManager
|
||||
import com.fsck.k9.mail.testing.security.SimpleTrustedSocketFactory
|
||||
import java.net.UnknownHostException
|
||||
import kotlin.test.Test
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.logging.testing.TestLogger
|
||||
import org.junit.Before
|
||||
|
||||
private const val USERNAME = "user"
|
||||
private const val PASSWORD = "password"
|
||||
private val CLIENT_CERTIFICATE_ALIAS: String? = null
|
||||
|
||||
class Pop3ServerSettingsValidatorTest {
|
||||
private val fakeTrustManager = FakeTrustManager()
|
||||
private val trustedSocketFactory = SimpleTrustedSocketFactory(fakeTrustManager)
|
||||
private val serverSettingsValidator = Pop3ServerSettingsValidator(trustedSocketFactory)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Log.logger = TestLogger()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid server settings should return Success`() {
|
||||
val server = startServer {
|
||||
output("+OK POP3 server greeting")
|
||||
expect("CAPA")
|
||||
output("+OK Listing of supported mechanisms follows")
|
||||
output("STLS")
|
||||
output("UIDL")
|
||||
output("SASL PLAIN")
|
||||
output(".")
|
||||
expect("AUTH PLAIN")
|
||||
output("+OK")
|
||||
expect("AHVzZXIAcGFzc3dvcmQ=")
|
||||
output("+OK")
|
||||
expect("STAT")
|
||||
output("+OK 2 320")
|
||||
expect("QUIT")
|
||||
closeConnection()
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "pop3",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result).isInstanceOf<ServerSettingsValidationResult.Success>()
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authentication error should return AuthenticationError`() {
|
||||
val server = startServer {
|
||||
output("+OK POP3 server greeting")
|
||||
expect("CAPA")
|
||||
output("+OK Listing of supported mechanisms follows")
|
||||
output("STLS")
|
||||
output("UIDL")
|
||||
output("SASL PLAIN")
|
||||
output(".")
|
||||
expect("AUTH PLAIN")
|
||||
output("+OK")
|
||||
expect("AHVzZXIAcGFzc3dvcmQ=")
|
||||
output("-ERR Authentication failed")
|
||||
expect("QUIT")
|
||||
closeConnection()
|
||||
closeConnection()
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "pop3",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result).isInstanceOf<ServerSettingsValidationResult.AuthenticationError>()
|
||||
.prop(ServerSettingsValidationResult.AuthenticationError::serverMessage)
|
||||
.isEqualTo("-ERR Authentication failed")
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error code instead of greeting should return ServerError`() {
|
||||
val server = startServer {
|
||||
output("-ERR Service currently not available")
|
||||
closeConnection()
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "pop3",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result).isInstanceOf<ServerSettingsValidationResult.ServerError>()
|
||||
.prop(ServerSettingsValidationResult.ServerError::serverMessage)
|
||||
.isEqualTo("-ERR Service currently not available")
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing capability should return MissingServerCapabilityError`() {
|
||||
val server = startServer {
|
||||
output("+OK POP3 server greeting")
|
||||
expect("CAPA")
|
||||
output("+OK Listing of supported mechanisms follows")
|
||||
output(".")
|
||||
expect("QUIT")
|
||||
closeConnection()
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "pop3",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result).isInstanceOf<ServerSettingsValidationResult.MissingServerCapabilityError>()
|
||||
.prop(ServerSettingsValidationResult.MissingServerCapabilityError::capabilityName).isEqualTo("STLS")
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `client certificate retrieval failure should return ClientCertificateRetrievalFailure`() {
|
||||
trustedSocketFactory.injectClientCertificateError(ClientCertificateError.RetrievalFailure)
|
||||
val server = startServer {
|
||||
output("+OK POP3 server greeting")
|
||||
expect("CAPA")
|
||||
output("+OK Listing of supported mechanisms follows")
|
||||
output("STLS")
|
||||
output(".")
|
||||
expect("STLS")
|
||||
output("+OK Begin TLS negotiation")
|
||||
startTls()
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "pop3",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result)
|
||||
.isInstanceOf<ServerSettingsValidationResult.ClientCertificateError.ClientCertificateRetrievalFailure>()
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `client certificate expired error should return ClientCertificateExpired`() {
|
||||
trustedSocketFactory.injectClientCertificateError(ClientCertificateError.CertificateExpired)
|
||||
val server = startServer {
|
||||
output("+OK POP3 server greeting")
|
||||
expect("CAPA")
|
||||
output("+OK Listing of supported mechanisms follows")
|
||||
output("STLS")
|
||||
output(".")
|
||||
expect("STLS")
|
||||
output("+OK Begin TLS negotiation")
|
||||
startTls()
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "pop3",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result)
|
||||
.isInstanceOf<ServerSettingsValidationResult.ClientCertificateError.ClientCertificateExpired>()
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `certificate error when trying to connect should return CertificateError`() {
|
||||
fakeTrustManager.shouldThrowException = true
|
||||
val server = startServer {
|
||||
output("+OK POP3 server greeting")
|
||||
expect("CAPA")
|
||||
output("+OK Listing of supported mechanisms follows")
|
||||
output("STLS")
|
||||
output("UIDL")
|
||||
output("SASL PLAIN")
|
||||
output(".")
|
||||
expect("STLS")
|
||||
output("+OK Begin TLS negotiation")
|
||||
startTls()
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "pop3",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result).isInstanceOf<ServerSettingsValidationResult.CertificateError>()
|
||||
.prop(ServerSettingsValidationResult.CertificateError::certificateChain).hasSize(1)
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-existent hostname should return NetworkError`() {
|
||||
val serverSettings = ServerSettings(
|
||||
type = "pop3",
|
||||
host = "domain.invalid",
|
||||
port = 587,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result).isInstanceOf<ServerSettingsValidationResult.NetworkError>()
|
||||
.prop(ServerSettingsValidationResult.NetworkError::exception)
|
||||
.isInstanceOf<UnknownHostException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ServerSettings with wrong type should throw`() {
|
||||
val serverSettings = ServerSettings(
|
||||
type = "wrong",
|
||||
host = "domain.invalid",
|
||||
port = 587,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
assertFailure {
|
||||
serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
}.isInstanceOf<IllegalArgumentException>()
|
||||
}
|
||||
|
||||
private fun startServer(block: MockPop3Server.() -> Unit): MockPop3Server {
|
||||
return MockPop3Server().apply {
|
||||
block()
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
package com.fsck.k9.mail.store.pop3
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isSameInstanceAs
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.Socket
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.logging.testing.TestLogger
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.doThrow
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.stubbing
|
||||
|
||||
class Pop3StoreTest {
|
||||
private val trustedSocketFactory = mock<TrustedSocketFactory>()
|
||||
private val store: Pop3Store = Pop3Store(createServerSettings(), trustedSocketFactory)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Log.logger = TestLogger()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolder() should return same instance every time`() {
|
||||
val folderOne = store.getFolder("TestFolder")
|
||||
val folderTwo = store.getFolder("TestFolder")
|
||||
|
||||
assertThat(folderTwo).isSameInstanceAs(folderOne)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolder() should return folder with correct server ID`() {
|
||||
val folder = store.getFolder("TestFolder")
|
||||
|
||||
assertThat(folder.serverId).isEqualTo("TestFolder")
|
||||
}
|
||||
|
||||
@Test(expected = MessagingException::class)
|
||||
fun `checkSettings() with TrustedSocketFactory throwing should throw MessagingException`() {
|
||||
stubbing(trustedSocketFactory) {
|
||||
on { createSocket(null, HOST, 12345, null) } doThrow IOException()
|
||||
}
|
||||
|
||||
store.checkSettings()
|
||||
}
|
||||
|
||||
@Test(expected = MessagingException::class)
|
||||
fun `checkSettings() with UIDL command not supported should throw MessagingException`() {
|
||||
setupSocketWithResponse(
|
||||
INITIAL_RESPONSE +
|
||||
CAPA_RESPONSE +
|
||||
AUTH_PLAIN_AUTHENTICATED_RESPONSE +
|
||||
STAT_RESPONSE +
|
||||
UIDL_UNSUPPORTED_RESPONSE,
|
||||
)
|
||||
|
||||
store.checkSettings()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checkSettings() with UIDL supported`() {
|
||||
setupSocketWithResponse(
|
||||
INITIAL_RESPONSE +
|
||||
CAPA_RESPONSE +
|
||||
AUTH_PLAIN_AUTHENTICATED_RESPONSE +
|
||||
STAT_RESPONSE +
|
||||
UIDL_SUPPORTED_RESPONSE,
|
||||
)
|
||||
|
||||
store.checkSettings()
|
||||
}
|
||||
|
||||
private fun createServerSettings(): ServerSettings {
|
||||
return ServerSettings(
|
||||
type = "pop3",
|
||||
host = HOST,
|
||||
port = 12345,
|
||||
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = "user",
|
||||
password = "password",
|
||||
clientCertificateAlias = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupSocketWithResponse(response: String): ByteArrayOutputStream {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
|
||||
val socket = mock<Socket> {
|
||||
on { isConnected } doReturn true
|
||||
on { isClosed } doReturn false
|
||||
on { getOutputStream() } doReturn outputStream
|
||||
on { getInputStream() } doReturn response.byteInputStream()
|
||||
}
|
||||
|
||||
stubbing(trustedSocketFactory) {
|
||||
on { createSocket(null, HOST, 12345, null) } doReturn socket
|
||||
}
|
||||
|
||||
return outputStream
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val HOST = "127.0.0.1"
|
||||
private const val INITIAL_RESPONSE = "+OK POP3 server greeting\r\n"
|
||||
|
||||
private const val CAPA_RESPONSE = "+OK Listing of supported mechanisms follows\r\n" +
|
||||
"SASL PLAIN CRAM-MD5 EXTERNAL\r\n" +
|
||||
".\r\n"
|
||||
|
||||
private const val AUTH_PLAIN_AUTHENTICATED_RESPONSE = "+OK\r\n" + "+OK\r\n"
|
||||
|
||||
private const val STAT_RESPONSE = "+OK 20 0\r\n"
|
||||
|
||||
private const val UIDL_UNSUPPORTED_RESPONSE = "-ERR UIDL unsupported\r\n"
|
||||
private const val UIDL_SUPPORTED_RESPONSE = "+OK UIDL supported\r\n"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue