Merge pull request #6237 from thundernest/convert_to_kotlin
Convert some IMAP-related classes to Kotlin
This commit is contained in:
commit
2b0a8469b8
12 changed files with 1757 additions and 2009 deletions
|
@ -1,36 +0,0 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
import com.fsck.k9.mail.AuthType;
|
||||
import com.fsck.k9.mail.ConnectionSecurity;
|
||||
|
||||
|
||||
/**
|
||||
* Settings source for IMAP. Implemented in order to remove coupling between {@link ImapStore} and {@link ImapConnection}.
|
||||
*/
|
||||
interface ImapSettings {
|
||||
String getHost();
|
||||
|
||||
int getPort();
|
||||
|
||||
ConnectionSecurity getConnectionSecurity();
|
||||
|
||||
AuthType getAuthType();
|
||||
|
||||
String getUsername();
|
||||
|
||||
String getPassword();
|
||||
|
||||
String getClientCertificateAlias();
|
||||
|
||||
boolean useCompression();
|
||||
|
||||
String getPathPrefix();
|
||||
|
||||
void setPathPrefix(String prefix);
|
||||
|
||||
String getPathDelimiter();
|
||||
|
||||
void setPathDelimiter(String delimiter);
|
||||
|
||||
void setCombinedPrefix(String prefix);
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
|
||||
/**
|
||||
* Settings source for IMAP. Implemented in order to remove coupling between [ImapStore] and [ImapConnection].
|
||||
*/
|
||||
internal interface ImapSettings {
|
||||
val host: String
|
||||
val port: Int
|
||||
val connectionSecurity: ConnectionSecurity
|
||||
val authType: AuthType
|
||||
val username: String
|
||||
val password: String?
|
||||
val clientCertificateAlias: String?
|
||||
fun useCompression(): Boolean
|
||||
|
||||
var pathPrefix: String?
|
||||
var pathDelimiter: String?
|
||||
fun setCombinedPrefix(prefix: String?)
|
||||
}
|
|
@ -1,929 +0,0 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ConnectException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketAddress;
|
||||
import java.net.SocketException;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.Security;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.Inflater;
|
||||
import java.util.zip.InflaterInputStream;
|
||||
|
||||
import com.fsck.k9.logging.Timber;
|
||||
import com.fsck.k9.mail.Authentication;
|
||||
import com.fsck.k9.mail.AuthenticationFailedException;
|
||||
import com.fsck.k9.mail.CertificateValidationException;
|
||||
import com.fsck.k9.mail.ConnectionSecurity;
|
||||
import com.fsck.k9.mail.K9MailLib;
|
||||
import com.fsck.k9.mail.MessagingException;
|
||||
import com.fsck.k9.mail.filter.Base64;
|
||||
import com.fsck.k9.mail.filter.PeekableInputStream;
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider;
|
||||
import com.fsck.k9.mail.oauth.XOAuth2ChallengeParser;
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
|
||||
import com.fsck.k9.mail.store.imap.IdGrouper.GroupedIds;
|
||||
import com.fsck.k9.sasl.OAuthBearer;
|
||||
import com.jcraft.jzlib.JZlib;
|
||||
import com.jcraft.jzlib.ZOutputStream;
|
||||
import javax.net.ssl.SSLException;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import static com.fsck.k9.mail.ConnectionSecurity.STARTTLS_REQUIRED;
|
||||
import static com.fsck.k9.mail.K9MailLib.DEBUG_PROTOCOL_IMAP;
|
||||
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.imap.ImapResponseParser.equalsIgnoreCase;
|
||||
|
||||
|
||||
/**
|
||||
* A cacheable class that stores the details for a single IMAP connection.
|
||||
*/
|
||||
class RealImapConnection implements ImapConnection {
|
||||
private static final int BUFFER_SIZE = 1024;
|
||||
|
||||
/* The below limits are 20 octets less than the recommended limits, in order to compensate for
|
||||
* the length of the command tag, the space after the tag and the CRLF at the end of the command
|
||||
* (these are not taken into account when calculating the length of the command). For more
|
||||
* information, refer to section 4 of RFC 7162.
|
||||
*
|
||||
* The length limit for servers supporting the CONDSTORE extension is large in order to support
|
||||
* the QRESYNC parameter to the SELECT/EXAMINE commands, which accept a list of known message
|
||||
* sequence numbers as well as their corresponding UIDs.
|
||||
*/
|
||||
private static final int LENGTH_LIMIT_WITHOUT_CONDSTORE = 980;
|
||||
private static final int LENGTH_LIMIT_WITH_CONDSTORE = 8172;
|
||||
|
||||
|
||||
private final OAuth2TokenProvider oauthTokenProvider;
|
||||
private final TrustedSocketFactory socketFactory;
|
||||
private final int socketConnectTimeout;
|
||||
private final int socketReadTimeout;
|
||||
private final int connectionGeneration;
|
||||
|
||||
private Socket socket;
|
||||
private PeekableInputStream inputStream;
|
||||
private OutputStream outputStream;
|
||||
private ImapResponseParser responseParser;
|
||||
private int nextCommandTag;
|
||||
private Set<String> capabilities = new HashSet<>();
|
||||
private ImapSettings settings;
|
||||
private Exception stacktraceForClose;
|
||||
private boolean open = false;
|
||||
private boolean retryOAuthWithNewToken = true;
|
||||
|
||||
|
||||
public RealImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory,
|
||||
OAuth2TokenProvider oauthTokenProvider, int connectionGeneration) {
|
||||
this.settings = settings;
|
||||
this.socketFactory = socketFactory;
|
||||
this.oauthTokenProvider = oauthTokenProvider;
|
||||
this.socketConnectTimeout = SOCKET_CONNECT_TIMEOUT;
|
||||
this.socketReadTimeout = SOCKET_READ_TIMEOUT;
|
||||
this.connectionGeneration = connectionGeneration;
|
||||
}
|
||||
|
||||
public RealImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory,
|
||||
OAuth2TokenProvider oauthTokenProvider, int socketConnectTimeout, int socketReadTimeout,
|
||||
int connectionGeneration) {
|
||||
this.settings = settings;
|
||||
this.socketFactory = socketFactory;
|
||||
this.oauthTokenProvider = oauthTokenProvider;
|
||||
this.socketConnectTimeout = socketConnectTimeout;
|
||||
this.socketReadTimeout = socketReadTimeout;
|
||||
this.connectionGeneration = connectionGeneration;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void open() throws IOException, MessagingException {
|
||||
if (open) {
|
||||
return;
|
||||
} else if (stacktraceForClose != null) {
|
||||
throw new IllegalStateException("open() called after close(). " +
|
||||
"Check wrapped exception to see where close() was called.", stacktraceForClose);
|
||||
}
|
||||
|
||||
open = true;
|
||||
boolean authSuccess = false;
|
||||
nextCommandTag = 1;
|
||||
|
||||
adjustDNSCacheTTL();
|
||||
|
||||
try {
|
||||
socket = connect();
|
||||
configureSocket();
|
||||
setUpStreamsAndParserFromSocket();
|
||||
|
||||
readInitialResponse();
|
||||
requestCapabilitiesIfNecessary();
|
||||
|
||||
upgradeToTlsIfNecessary();
|
||||
|
||||
List<ImapResponse> responses = authenticate();
|
||||
authSuccess = true;
|
||||
|
||||
extractOrRequestCapabilities(responses);
|
||||
|
||||
enableCompressionIfRequested();
|
||||
|
||||
retrievePathPrefixIfNecessary();
|
||||
retrievePathDelimiterIfNecessary();
|
||||
|
||||
} catch (SSLException e) {
|
||||
handleSslException(e);
|
||||
} catch (ConnectException e) {
|
||||
handleConnectException(e);
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new MessagingException("Unable to open connection to IMAP server due to security error.", e);
|
||||
} finally {
|
||||
if (!authSuccess) {
|
||||
Timber.e("Failed to login, closing connection for %s", getLogId());
|
||||
close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSslException(SSLException e) throws CertificateValidationException, SSLException {
|
||||
if (e.getCause() instanceof CertificateException) {
|
||||
throw new CertificateValidationException(e.getMessage(), e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void handleConnectException(ConnectException e) throws ConnectException {
|
||||
String message = e.getMessage();
|
||||
String[] tokens = message.split("-");
|
||||
|
||||
if (tokens.length > 1 && tokens[1] != null) {
|
||||
Timber.e(e, "Stripping host/port from ConnectionException for %s", getLogId());
|
||||
throw new ConnectException(tokens[1].trim());
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean isConnected() {
|
||||
return inputStream != null && outputStream != null && socket != null &&
|
||||
socket.isConnected() && !socket.isClosed();
|
||||
}
|
||||
|
||||
private void adjustDNSCacheTTL() {
|
||||
try {
|
||||
Security.setProperty("networkaddress.cache.ttl", "0");
|
||||
} catch (Exception e) {
|
||||
Timber.w(e, "Could not set DNS ttl to 0 for %s", getLogId());
|
||||
}
|
||||
|
||||
try {
|
||||
Security.setProperty("networkaddress.cache.negative.ttl", "0");
|
||||
} catch (Exception e) {
|
||||
Timber.w(e, "Could not set DNS negative ttl to 0 for %s", getLogId());
|
||||
}
|
||||
}
|
||||
|
||||
private Socket connect() throws GeneralSecurityException, MessagingException, IOException {
|
||||
Exception connectException = null;
|
||||
|
||||
InetAddress[] inetAddresses = InetAddress.getAllByName(settings.getHost());
|
||||
for (InetAddress address : inetAddresses) {
|
||||
try {
|
||||
return connectToAddress(address);
|
||||
} catch (IOException e) {
|
||||
Timber.w(e, "Could not connect to %s", address);
|
||||
connectException = e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new MessagingException("Cannot connect to host", connectException);
|
||||
}
|
||||
|
||||
private Socket connectToAddress(InetAddress address) throws NoSuchAlgorithmException, KeyManagementException,
|
||||
MessagingException, IOException {
|
||||
|
||||
String host = settings.getHost();
|
||||
int port = settings.getPort();
|
||||
String clientCertificateAlias = settings.getClientCertificateAlias();
|
||||
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) {
|
||||
Timber.d("Connecting to %s as %s", host, address);
|
||||
}
|
||||
|
||||
SocketAddress socketAddress = new InetSocketAddress(address, port);
|
||||
|
||||
Socket socket;
|
||||
if (settings.getConnectionSecurity() == ConnectionSecurity.SSL_TLS_REQUIRED) {
|
||||
socket = socketFactory.createSocket(null, host, port, clientCertificateAlias);
|
||||
} else {
|
||||
socket = new Socket();
|
||||
}
|
||||
|
||||
socket.connect(socketAddress, socketConnectTimeout);
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
private void configureSocket() throws SocketException {
|
||||
setSocketDefaultReadTimeout();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSocketDefaultReadTimeout() throws SocketException {
|
||||
setSocketReadTimeout(socketReadTimeout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void setSocketReadTimeout(int timeout) throws SocketException {
|
||||
if (socket != null) {
|
||||
socket.setSoTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private void setUpStreamsAndParserFromSocket() throws IOException {
|
||||
setUpStreamsAndParser(socket.getInputStream(), socket.getOutputStream());
|
||||
}
|
||||
|
||||
private void setUpStreamsAndParser(InputStream input, OutputStream output) {
|
||||
inputStream = new PeekableInputStream(new BufferedInputStream(input, BUFFER_SIZE));
|
||||
responseParser = new ImapResponseParser(inputStream);
|
||||
outputStream = new BufferedOutputStream(output, BUFFER_SIZE);
|
||||
}
|
||||
|
||||
private void readInitialResponse() throws IOException {
|
||||
ImapResponse initialResponse = responseParser.readResponse();
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) {
|
||||
Timber.v("%s <<< %s", getLogId(), initialResponse);
|
||||
}
|
||||
extractCapabilities(Collections.singletonList(initialResponse));
|
||||
}
|
||||
|
||||
private boolean extractCapabilities(List<ImapResponse> responses) {
|
||||
CapabilityResponse capabilityResponse = CapabilityResponse.parse(responses);
|
||||
if (capabilityResponse == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Set<String> receivedCapabilities = capabilityResponse.getCapabilities();
|
||||
Timber.d("Saving %s capabilities for %s", receivedCapabilities, getLogId());
|
||||
capabilities = receivedCapabilities;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void extractOrRequestCapabilities(List<ImapResponse> responses) throws IOException, MessagingException {
|
||||
if (!extractCapabilities(responses)) {
|
||||
Timber.i("Did not get capabilities in post-auth banner, requesting CAPABILITY for %s", getLogId());
|
||||
requestCapabilities();
|
||||
}
|
||||
}
|
||||
|
||||
private void requestCapabilitiesIfNecessary() throws IOException, MessagingException {
|
||||
if (!capabilities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.i("Did not get capabilities in banner, requesting CAPABILITY for %s", getLogId());
|
||||
}
|
||||
requestCapabilities();
|
||||
}
|
||||
|
||||
private void requestCapabilities() throws IOException, MessagingException {
|
||||
if (!extractCapabilities(executeSimpleCommand(Commands.CAPABILITY))) {
|
||||
throw new MessagingException("Invalid CAPABILITY response received");
|
||||
}
|
||||
}
|
||||
|
||||
private void upgradeToTlsIfNecessary() throws IOException, MessagingException, GeneralSecurityException {
|
||||
if (settings.getConnectionSecurity() == STARTTLS_REQUIRED) {
|
||||
upgradeToTls();
|
||||
}
|
||||
}
|
||||
|
||||
private void upgradeToTls() throws IOException, MessagingException, GeneralSecurityException {
|
||||
if (!hasCapability(Capabilities.STARTTLS)) {
|
||||
/*
|
||||
* This exception triggers a "Certificate error"
|
||||
* notification that takes the user to the incoming
|
||||
* server settings for review. This might be needed if
|
||||
* the account was configured with an obsolete
|
||||
* "STARTTLS (if available)" setting.
|
||||
*/
|
||||
throw new CertificateValidationException("STARTTLS connection security not available");
|
||||
}
|
||||
|
||||
startTLS();
|
||||
}
|
||||
|
||||
private void startTLS() throws IOException, MessagingException, GeneralSecurityException {
|
||||
executeSimpleCommand(Commands.STARTTLS);
|
||||
|
||||
String host = settings.getHost();
|
||||
int port = settings.getPort();
|
||||
String clientCertificateAlias = settings.getClientCertificateAlias();
|
||||
|
||||
socket = socketFactory.createSocket(socket, host, port, clientCertificateAlias);
|
||||
configureSocket();
|
||||
setUpStreamsAndParserFromSocket();
|
||||
|
||||
// Per RFC 2595 (3.1): Once TLS has been started, reissue CAPABILITY command
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.i("Updating capabilities after STARTTLS for %s", getLogId());
|
||||
}
|
||||
|
||||
requestCapabilities();
|
||||
}
|
||||
|
||||
private List<ImapResponse> authenticate() throws MessagingException, IOException {
|
||||
switch (settings.getAuthType()) {
|
||||
case XOAUTH2:
|
||||
if (oauthTokenProvider == null) {
|
||||
throw new MessagingException("No OAuthToken Provider available.");
|
||||
} else if (!hasCapability(Capabilities.SASL_IR)) {
|
||||
throw new MessagingException("SASL-IR capability is missing.");
|
||||
} else if (hasCapability(Capabilities.AUTH_OAUTHBEARER)) {
|
||||
return authWithOAuthToken(OAuthMethod.OAUTHBEARER);
|
||||
} else if (hasCapability(Capabilities.AUTH_XOAUTH2)) {
|
||||
return authWithOAuthToken(OAuthMethod.XOAUTH2);
|
||||
} else {
|
||||
throw new MessagingException("Server doesn't support SASL OAUTHBEARER or XOAUTH2.");
|
||||
}
|
||||
case CRAM_MD5: {
|
||||
if (hasCapability(Capabilities.AUTH_CRAM_MD5)) {
|
||||
return authCramMD5();
|
||||
} else {
|
||||
throw new MessagingException("Server doesn't support encrypted passwords using CRAM-MD5.");
|
||||
}
|
||||
}
|
||||
case PLAIN: {
|
||||
if (hasCapability(Capabilities.AUTH_PLAIN)) {
|
||||
return saslAuthPlainWithLoginFallback();
|
||||
} else if (!hasCapability(Capabilities.LOGINDISABLED)) {
|
||||
return login();
|
||||
} else {
|
||||
throw new MessagingException("Server doesn't support unencrypted passwords using AUTH=PLAIN " +
|
||||
"and LOGIN is disabled.");
|
||||
}
|
||||
}
|
||||
case EXTERNAL: {
|
||||
if (hasCapability(Capabilities.AUTH_EXTERNAL)) {
|
||||
return saslAuthExternal();
|
||||
} else {
|
||||
// Provide notification to user of a problem authenticating using client certificates
|
||||
throw new CertificateValidationException(CertificateValidationException.Reason.MissingCapability);
|
||||
}
|
||||
}
|
||||
default: {
|
||||
throw new MessagingException("Unhandled authentication method found in the server settings (bug).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ImapResponse> authWithOAuthToken(OAuthMethod method) throws IOException, MessagingException {
|
||||
retryOAuthWithNewToken = true;
|
||||
try {
|
||||
return attemptOAuth(method);
|
||||
} catch (NegativeImapResponseException e) {
|
||||
//TODO: Check response code so we don't needlessly invalidate the token.
|
||||
oauthTokenProvider.invalidateToken();
|
||||
|
||||
if (!retryOAuthWithNewToken) {
|
||||
throw handlePermanentOAuthFailure(e);
|
||||
} else {
|
||||
return handleTemporaryOAuthFailure(method, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private AuthenticationFailedException handlePermanentOAuthFailure(NegativeImapResponseException e) {
|
||||
Timber.v(e, "Permanent failure during authentication using OAuth token");
|
||||
return new AuthenticationFailedException(e.getMessage(), e, e.getAlertText());
|
||||
}
|
||||
|
||||
private List<ImapResponse> handleTemporaryOAuthFailure(OAuthMethod method, NegativeImapResponseException e)
|
||||
throws IOException, MessagingException {
|
||||
//We got a response indicating a retry might succeed after token refresh
|
||||
//We could avoid this if we had a reasonable chance of knowing
|
||||
//if a token was invalid before use (e.g. due to expiry). But we don't
|
||||
//This is the intended behaviour per AccountManager
|
||||
|
||||
Timber.v(e, "Temporary failure - retrying with new token");
|
||||
try {
|
||||
return attemptOAuth(method);
|
||||
} catch (NegativeImapResponseException e2) {
|
||||
//Okay, we failed on a new token.
|
||||
//Invalidate the token anyway but assume it's permanent.
|
||||
Timber.v(e, "Authentication exception for new token, permanent error assumed");
|
||||
oauthTokenProvider.invalidateToken();
|
||||
throw handlePermanentOAuthFailure(e2);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ImapResponse> attemptOAuth(OAuthMethod method) throws MessagingException, IOException {
|
||||
String token = oauthTokenProvider.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT);
|
||||
String authString = method.buildInitialClientResponse(settings.getUsername(), token);
|
||||
String tag = sendSaslIrCommand(method.getCommand(), authString, true);
|
||||
|
||||
return responseParser.readStatusResponse(tag, method.getCommand(), getLogId(),
|
||||
new UntaggedHandler() {
|
||||
@Override
|
||||
public void handleAsyncUntaggedResponse(ImapResponse response) throws IOException {
|
||||
handleOAuthUntaggedResponse(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleOAuthUntaggedResponse(ImapResponse response) throws IOException {
|
||||
if (!response.isContinuationRequested()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.isString(0)) {
|
||||
retryOAuthWithNewToken = XOAuth2ChallengeParser.shouldRetry(response.getString(0), settings.getHost());
|
||||
}
|
||||
|
||||
outputStream.write("\r\n".getBytes());
|
||||
outputStream.flush();
|
||||
}
|
||||
|
||||
private List<ImapResponse> authCramMD5() throws MessagingException, IOException {
|
||||
String command = Commands.AUTHENTICATE_CRAM_MD5;
|
||||
String tag = sendCommand(command, false);
|
||||
|
||||
ImapResponse response = readContinuationResponse(tag);
|
||||
if (response.size() != 1 || !(response.get(0) instanceof String)) {
|
||||
throw new MessagingException("Invalid Cram-MD5 nonce received");
|
||||
}
|
||||
|
||||
byte[] b64Nonce = response.getString(0).getBytes();
|
||||
byte[] b64CRAM = Authentication.computeCramMd5Bytes(settings.getUsername(), settings.getPassword(), b64Nonce);
|
||||
|
||||
outputStream.write(b64CRAM);
|
||||
outputStream.write('\r');
|
||||
outputStream.write('\n');
|
||||
outputStream.flush();
|
||||
|
||||
try {
|
||||
return responseParser.readStatusResponse(tag, command, getLogId(), null);
|
||||
} catch (NegativeImapResponseException e) {
|
||||
throw handleAuthenticationFailure(e);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ImapResponse> saslAuthPlainWithLoginFallback() throws IOException, MessagingException {
|
||||
try {
|
||||
return saslAuthPlain();
|
||||
} catch (AuthenticationFailedException e) {
|
||||
if (!isConnected()) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
return login();
|
||||
}
|
||||
}
|
||||
|
||||
private List<ImapResponse> saslAuthPlain() throws IOException, MessagingException {
|
||||
String command = Commands.AUTHENTICATE_PLAIN;
|
||||
String tag = sendCommand(command, false);
|
||||
|
||||
readContinuationResponse(tag);
|
||||
|
||||
String credentials = "\000" + settings.getUsername() + "\000" + settings.getPassword();
|
||||
byte[] encodedCredentials = Base64.encodeBase64(credentials.getBytes());
|
||||
|
||||
outputStream.write(encodedCredentials);
|
||||
outputStream.write('\r');
|
||||
outputStream.write('\n');
|
||||
outputStream.flush();
|
||||
|
||||
try {
|
||||
return responseParser.readStatusResponse(tag, command, getLogId(), null);
|
||||
} catch (NegativeImapResponseException e) {
|
||||
throw handleAuthenticationFailure(e);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ImapResponse> login() throws IOException, MessagingException {
|
||||
/*
|
||||
* Use quoted strings which permit spaces and quotes. (Using IMAP
|
||||
* string literals would be better, but some servers are broken
|
||||
* and don't parse them correctly.)
|
||||
*/
|
||||
|
||||
// escape double-quotes and backslash characters with a backslash
|
||||
Pattern p = Pattern.compile("[\\\\\"]");
|
||||
String replacement = "\\\\$0";
|
||||
String username = p.matcher(settings.getUsername()).replaceAll(replacement);
|
||||
String password = p.matcher(settings.getPassword()).replaceAll(replacement);
|
||||
|
||||
try {
|
||||
String command = String.format(Commands.LOGIN + " \"%s\" \"%s\"", username, password);
|
||||
return executeSimpleCommand(command, true);
|
||||
} catch (NegativeImapResponseException e) {
|
||||
throw handleAuthenticationFailure(e);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ImapResponse> saslAuthExternal() throws IOException, MessagingException {
|
||||
try {
|
||||
String command = Commands.AUTHENTICATE_EXTERNAL + " " + Base64.encode(settings.getUsername());
|
||||
return executeSimpleCommand(command, false);
|
||||
} catch (NegativeImapResponseException e) {
|
||||
/*
|
||||
* Provide notification to the user of a problem authenticating
|
||||
* using client certificates. We don't use an
|
||||
* AuthenticationFailedException because that would trigger a
|
||||
* "Username or password incorrect" notification in
|
||||
* AccountSetupCheckSettings.
|
||||
*/
|
||||
throw new CertificateValidationException(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private MessagingException handleAuthenticationFailure(NegativeImapResponseException e) {
|
||||
ImapResponse lastResponse = e.getLastResponse();
|
||||
String responseCode = ResponseCodeExtractor.getResponseCode(lastResponse);
|
||||
|
||||
// If there's no response code we simply assume it was an authentication failure.
|
||||
if (responseCode == null || responseCode.equals(ResponseCodeExtractor.AUTHENTICATION_FAILED)) {
|
||||
if (e.wasByeResponseReceived()) {
|
||||
close();
|
||||
}
|
||||
|
||||
return new AuthenticationFailedException(e.getMessage());
|
||||
} else {
|
||||
close();
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
private void enableCompressionIfRequested() throws IOException, MessagingException {
|
||||
if (hasCapability(Capabilities.COMPRESS_DEFLATE) && settings.useCompression()) {
|
||||
enableCompression();
|
||||
}
|
||||
}
|
||||
|
||||
private void enableCompression() throws IOException, MessagingException {
|
||||
try {
|
||||
executeSimpleCommand(Commands.COMPRESS_DEFLATE);
|
||||
} catch (NegativeImapResponseException e) {
|
||||
Timber.d(e, "Unable to negotiate compression: ");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
InflaterInputStream input = new InflaterInputStream(socket.getInputStream(), new Inflater(true));
|
||||
ZOutputStream output = new ZOutputStream(socket.getOutputStream(), JZlib.Z_BEST_SPEED, true);
|
||||
output.setFlushMode(JZlib.Z_PARTIAL_FLUSH);
|
||||
|
||||
setUpStreamsAndParser(input, output);
|
||||
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.i("Compression enabled for %s", getLogId());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
close();
|
||||
Timber.e(e, "Error enabling compression");
|
||||
}
|
||||
}
|
||||
|
||||
private void retrievePathPrefixIfNecessary() throws IOException, MessagingException {
|
||||
if (settings.getPathPrefix() != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasCapability(Capabilities.NAMESPACE)) {
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.i("pathPrefix is unset and server has NAMESPACE capability");
|
||||
}
|
||||
handleNamespace();
|
||||
} else {
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.i("pathPrefix is unset but server does not have NAMESPACE capability");
|
||||
}
|
||||
settings.setPathPrefix("");
|
||||
}
|
||||
}
|
||||
|
||||
private void handleNamespace() throws IOException, MessagingException {
|
||||
List<ImapResponse> responses = executeSimpleCommand(Commands.NAMESPACE);
|
||||
|
||||
NamespaceResponse namespaceResponse = NamespaceResponse.parse(responses);
|
||||
if (namespaceResponse != null) {
|
||||
String prefix = namespaceResponse.getPrefix();
|
||||
String hierarchyDelimiter = namespaceResponse.getHierarchyDelimiter();
|
||||
|
||||
settings.setPathPrefix(prefix);
|
||||
settings.setPathDelimiter(hierarchyDelimiter);
|
||||
settings.setCombinedPrefix(null);
|
||||
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.d("Got path '%s' and separator '%s'", prefix, hierarchyDelimiter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void retrievePathDelimiterIfNecessary() throws IOException, MessagingException {
|
||||
if (settings.getPathDelimiter() == null) {
|
||||
retrievePathDelimiter();
|
||||
}
|
||||
}
|
||||
|
||||
private void retrievePathDelimiter() throws IOException, MessagingException {
|
||||
List<ImapResponse> listResponses;
|
||||
try {
|
||||
listResponses = executeSimpleCommand(Commands.LIST + " \"\" \"\"");
|
||||
} catch (NegativeImapResponseException e) {
|
||||
Timber.d(e, "Error getting path delimiter using LIST command");
|
||||
return;
|
||||
}
|
||||
|
||||
for (ImapResponse response : listResponses) {
|
||||
if (isListResponse(response)) {
|
||||
String hierarchyDelimiter = response.getString(2);
|
||||
settings.setPathDelimiter(hierarchyDelimiter);
|
||||
settings.setCombinedPrefix(null);
|
||||
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.d("Got path delimiter '%s' for %s", settings.getPathDelimiter(), getLogId());
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isListResponse(ImapResponse response) {
|
||||
boolean responseTooShort = response.size() < 4;
|
||||
if (responseTooShort) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean isListResponse = equalsIgnoreCase(response.get(0), Responses.LIST);
|
||||
boolean hierarchyDelimiterValid = response.get(2) instanceof String;
|
||||
|
||||
return isListResponse && hierarchyDelimiterValid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasCapability(@NotNull String capability) throws IOException, MessagingException {
|
||||
if (!open) {
|
||||
open();
|
||||
}
|
||||
|
||||
return capabilities.contains(capability.toUpperCase(Locale.US));
|
||||
}
|
||||
|
||||
public boolean isCondstoreCapable() throws IOException, MessagingException {
|
||||
return hasCapability(Capabilities.CONDSTORE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isIdleCapable() {
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.v("Connection %s has %d capabilities", getLogId(), capabilities.size());
|
||||
}
|
||||
|
||||
return capabilities.contains(Capabilities.IDLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isUidPlusCapable() {
|
||||
return capabilities.contains(Capabilities.UID_PLUS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
open = false;
|
||||
stacktraceForClose = new Exception();
|
||||
|
||||
IOUtils.closeQuietly(inputStream);
|
||||
IOUtils.closeQuietly(outputStream);
|
||||
IOUtils.closeQuietly(socket);
|
||||
|
||||
inputStream = null;
|
||||
outputStream = null;
|
||||
socket = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public synchronized OutputStream getOutputStream() {
|
||||
return outputStream;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public String getLogId() {
|
||||
return "conn" + hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public synchronized List<ImapResponse> executeSimpleCommand(@NotNull String command)
|
||||
throws IOException, MessagingException {
|
||||
return executeSimpleCommand(command, false);
|
||||
}
|
||||
|
||||
public List<ImapResponse> executeSimpleCommand(String command, boolean sensitive) throws IOException,
|
||||
MessagingException {
|
||||
String commandToLog = command;
|
||||
|
||||
if (sensitive && !K9MailLib.isDebugSensitive()) {
|
||||
commandToLog = "*sensitive*";
|
||||
}
|
||||
|
||||
String tag = sendCommand(command, sensitive);
|
||||
|
||||
try {
|
||||
return responseParser.readStatusResponse(tag, commandToLog, getLogId(), null);
|
||||
} catch (IOException e) {
|
||||
close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public synchronized List<ImapResponse> executeCommandWithIdSet(@NotNull String commandPrefix,
|
||||
@NotNull String commandSuffix, @NotNull Set<Long> ids) throws IOException, MessagingException {
|
||||
|
||||
GroupedIds groupedIds = IdGrouper.groupIds(ids);
|
||||
List<String> splitCommands = ImapCommandSplitter.splitCommand(
|
||||
commandPrefix, commandSuffix, groupedIds, getLineLengthLimit());
|
||||
|
||||
List<ImapResponse> responses = new ArrayList<>();
|
||||
for (String splitCommand : splitCommands) {
|
||||
responses.addAll(executeSimpleCommand(splitCommand));
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
public String sendSaslIrCommand(String command, String initialClientResponse, boolean sensitive)
|
||||
throws IOException, MessagingException {
|
||||
try {
|
||||
open();
|
||||
|
||||
String tag = Integer.toString(nextCommandTag++);
|
||||
String commandToSend = tag + " " + command + " " + initialClientResponse + "\r\n";
|
||||
outputStream.write(commandToSend.getBytes());
|
||||
outputStream.flush();
|
||||
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) {
|
||||
if (sensitive && !K9MailLib.isDebugSensitive()) {
|
||||
Timber.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", getLogId());
|
||||
} else {
|
||||
Timber.v("%s>>> %s %s %s", getLogId(), tag, command, initialClientResponse);
|
||||
}
|
||||
}
|
||||
|
||||
return tag;
|
||||
} catch (IOException | MessagingException e) {
|
||||
close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public synchronized String sendCommand(@NotNull String command, boolean sensitive)
|
||||
throws MessagingException, IOException {
|
||||
try {
|
||||
open();
|
||||
|
||||
String tag = Integer.toString(nextCommandTag++);
|
||||
String commandToSend = tag + " " + command + "\r\n";
|
||||
outputStream.write(commandToSend.getBytes());
|
||||
outputStream.flush();
|
||||
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) {
|
||||
if (sensitive && !K9MailLib.isDebugSensitive()) {
|
||||
Timber.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", getLogId());
|
||||
} else {
|
||||
Timber.v("%s>>> %s %s", getLogId(), tag, command);
|
||||
}
|
||||
}
|
||||
|
||||
return tag;
|
||||
} catch (IOException | MessagingException e) {
|
||||
close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void sendContinuation(@NotNull String continuation) throws IOException {
|
||||
outputStream.write(continuation.getBytes());
|
||||
outputStream.write('\r');
|
||||
outputStream.write('\n');
|
||||
outputStream.flush();
|
||||
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) {
|
||||
Timber.v("%s>>> %s", getLogId(), continuation);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public ImapResponse readResponse() throws IOException {
|
||||
return readResponse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public ImapResponse readResponse(@Nullable ImapResponseCallback callback) throws IOException {
|
||||
try {
|
||||
ImapResponse response = responseParser.readResponse(callback);
|
||||
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) {
|
||||
Timber.v("%s<<<%s", getLogId(), response);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (IOException e) {
|
||||
close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private ImapResponse readContinuationResponse(String tag) throws IOException, MessagingException {
|
||||
ImapResponse response;
|
||||
do {
|
||||
response = readResponse();
|
||||
|
||||
String responseTag = response.getTag();
|
||||
if (responseTag != null) {
|
||||
if (responseTag.equalsIgnoreCase(tag)) {
|
||||
throw new MessagingException("Command continuation aborted: " + response);
|
||||
} else {
|
||||
Timber.w("After sending tag %s, got tag response from previous command %s for %s",
|
||||
tag, response, getLogId());
|
||||
}
|
||||
}
|
||||
} while (!response.isContinuationRequested());
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
int getLineLengthLimit() throws IOException, MessagingException {
|
||||
return isCondstoreCapable() ? LENGTH_LIMIT_WITH_CONDSTORE : LENGTH_LIMIT_WITHOUT_CONDSTORE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getConnectionGeneration() {
|
||||
return connectionGeneration;
|
||||
}
|
||||
|
||||
|
||||
private enum OAuthMethod {
|
||||
XOAUTH2 {
|
||||
@Override
|
||||
String getCommand() {
|
||||
return Commands.AUTHENTICATE_XOAUTH2;
|
||||
}
|
||||
|
||||
@Override
|
||||
String buildInitialClientResponse(String username, String token) {
|
||||
return Authentication.computeXoauth(username, token);
|
||||
}
|
||||
},
|
||||
OAUTHBEARER {
|
||||
@Override
|
||||
String getCommand() {
|
||||
return Commands.AUTHENTICATE_OAUTHBEARER;
|
||||
}
|
||||
|
||||
@Override
|
||||
String buildInitialClientResponse(String username, String token) {
|
||||
return OAuthBearer.buildOAuthBearerInitialClientResponse(username, token);
|
||||
}
|
||||
};
|
||||
|
||||
abstract String getCommand();
|
||||
abstract String buildInitialClientResponse(String username, String token);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,898 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.logging.Timber
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.Authentication
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.CertificateValidationException
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import com.fsck.k9.mail.K9MailLib
|
||||
import com.fsck.k9.mail.MessagingException
|
||||
import com.fsck.k9.mail.NetworkTimeouts.SOCKET_CONNECT_TIMEOUT
|
||||
import com.fsck.k9.mail.NetworkTimeouts.SOCKET_READ_TIMEOUT
|
||||
import com.fsck.k9.mail.filter.Base64
|
||||
import com.fsck.k9.mail.filter.PeekableInputStream
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
|
||||
import com.fsck.k9.mail.oauth.XOAuth2ChallengeParser
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import com.fsck.k9.sasl.buildOAuthBearerInitialClientResponse
|
||||
import com.jcraft.jzlib.JZlib
|
||||
import com.jcraft.jzlib.ZOutputStream
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.ConnectException
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.net.SocketAddress
|
||||
import java.security.GeneralSecurityException
|
||||
import java.security.Security
|
||||
import java.security.cert.CertificateException
|
||||
import java.util.regex.Pattern
|
||||
import java.util.zip.Inflater
|
||||
import java.util.zip.InflaterInputStream
|
||||
import javax.net.ssl.SSLException
|
||||
import org.apache.commons.io.IOUtils
|
||||
|
||||
/**
|
||||
* A cacheable class that stores the details for a single IMAP connection.
|
||||
*/
|
||||
internal class RealImapConnection(
|
||||
private val settings: ImapSettings,
|
||||
private val socketFactory: TrustedSocketFactory,
|
||||
private val oauthTokenProvider: OAuth2TokenProvider?,
|
||||
override val connectionGeneration: Int,
|
||||
private val socketConnectTimeout: Int = SOCKET_CONNECT_TIMEOUT,
|
||||
private val socketReadTimeout: Int = SOCKET_READ_TIMEOUT
|
||||
) : ImapConnection {
|
||||
private var socket: Socket? = null
|
||||
private var inputStream: PeekableInputStream? = null
|
||||
private var imapOutputStream: OutputStream? = null
|
||||
private var responseParser: ImapResponseParser? = null
|
||||
private var nextCommandTag = 0
|
||||
private var capabilities = emptySet<String>()
|
||||
private var stacktraceForClose: Exception? = null
|
||||
private var open = false
|
||||
private var retryOAuthWithNewToken = true
|
||||
|
||||
@get:Synchronized
|
||||
override val outputStream: OutputStream
|
||||
get() = checkNotNull(imapOutputStream)
|
||||
|
||||
@Synchronized
|
||||
@Throws(IOException::class, MessagingException::class)
|
||||
override fun open() {
|
||||
if (open) {
|
||||
return
|
||||
} else if (stacktraceForClose != null) {
|
||||
throw IllegalStateException(
|
||||
"open() called after close(). Check wrapped exception to see where close() was called.",
|
||||
stacktraceForClose
|
||||
)
|
||||
}
|
||||
|
||||
open = true
|
||||
var authSuccess = false
|
||||
nextCommandTag = 1
|
||||
|
||||
adjustDNSCacheTTL()
|
||||
|
||||
try {
|
||||
socket = connect()
|
||||
configureSocket()
|
||||
setUpStreamsAndParserFromSocket()
|
||||
|
||||
readInitialResponse()
|
||||
requestCapabilitiesIfNecessary()
|
||||
|
||||
upgradeToTlsIfNecessary()
|
||||
|
||||
val responses = authenticate()
|
||||
authSuccess = true
|
||||
|
||||
extractOrRequestCapabilities(responses)
|
||||
|
||||
enableCompressionIfRequested()
|
||||
|
||||
retrievePathPrefixIfNecessary()
|
||||
retrievePathDelimiterIfNecessary()
|
||||
} catch (e: SSLException) {
|
||||
handleSslException(e)
|
||||
} catch (e: ConnectException) {
|
||||
handleConnectException(e)
|
||||
} catch (e: GeneralSecurityException) {
|
||||
throw MessagingException("Unable to open connection to IMAP server due to security error.", e)
|
||||
} finally {
|
||||
if (!authSuccess) {
|
||||
Timber.e("Failed to login, closing connection for %s", logId)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSslException(e: SSLException) {
|
||||
if (e.cause is CertificateException) {
|
||||
throw CertificateValidationException(e.message, e)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Remove this. There is no documentation on why this was added, there are no tests, and this is unlikely to
|
||||
// still work.
|
||||
private fun handleConnectException(e: ConnectException) {
|
||||
val message = e.message ?: throw e
|
||||
|
||||
val tokens = message.split("-")
|
||||
if (tokens.size > 1) {
|
||||
Timber.e(e, "Stripping host/port from ConnectionException for %s", logId)
|
||||
throw ConnectException(tokens[1].trim())
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
override val isConnected: Boolean
|
||||
get() {
|
||||
return inputStream != null && imapOutputStream != null &&
|
||||
socket.let { socket ->
|
||||
socket != null && socket.isConnected && !socket.isClosed
|
||||
}
|
||||
}
|
||||
|
||||
private fun adjustDNSCacheTTL() {
|
||||
try {
|
||||
Security.setProperty("networkaddress.cache.ttl", "0")
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Could not set DNS ttl to 0 for %s", logId)
|
||||
}
|
||||
|
||||
try {
|
||||
Security.setProperty("networkaddress.cache.negative.ttl", "0")
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Could not set DNS negative ttl to 0 for %s", logId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun connect(): Socket {
|
||||
val inetAddresses = InetAddress.getAllByName(settings.host)
|
||||
|
||||
var connectException: Exception? = null
|
||||
for (address in inetAddresses) {
|
||||
connectException = try {
|
||||
return connectToAddress(address)
|
||||
} catch (e: IOException) {
|
||||
Timber.w(e, "Could not connect to %s", address)
|
||||
e
|
||||
}
|
||||
}
|
||||
|
||||
throw MessagingException("Cannot connect to host", connectException)
|
||||
}
|
||||
|
||||
private fun connectToAddress(address: InetAddress): Socket {
|
||||
val host = settings.host
|
||||
val port = settings.port
|
||||
val clientCertificateAlias = settings.clientCertificateAlias
|
||||
|
||||
if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) {
|
||||
Timber.d("Connecting to %s as %s", host, address)
|
||||
}
|
||||
|
||||
val socketAddress: SocketAddress = InetSocketAddress(address, port)
|
||||
val socket = if (settings.connectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED) {
|
||||
socketFactory.createSocket(null, host, port, clientCertificateAlias)
|
||||
} else {
|
||||
Socket()
|
||||
}
|
||||
|
||||
socket.connect(socketAddress, socketConnectTimeout)
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
private fun configureSocket() {
|
||||
setSocketDefaultReadTimeout()
|
||||
}
|
||||
|
||||
override fun setSocketDefaultReadTimeout() {
|
||||
setSocketReadTimeout(socketReadTimeout)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun setSocketReadTimeout(timeout: Int) {
|
||||
socket?.soTimeout = timeout
|
||||
}
|
||||
|
||||
private fun setUpStreamsAndParserFromSocket() {
|
||||
val socket = checkNotNull(socket)
|
||||
|
||||
setUpStreamsAndParser(socket.getInputStream(), socket.getOutputStream())
|
||||
}
|
||||
|
||||
private fun setUpStreamsAndParser(input: InputStream, output: OutputStream) {
|
||||
inputStream = PeekableInputStream(BufferedInputStream(input, BUFFER_SIZE))
|
||||
responseParser = ImapResponseParser(inputStream)
|
||||
imapOutputStream = BufferedOutputStream(output, BUFFER_SIZE)
|
||||
}
|
||||
|
||||
private fun readInitialResponse() {
|
||||
val responseParser = checkNotNull(responseParser)
|
||||
|
||||
val initialResponse = responseParser.readResponse()
|
||||
|
||||
if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) {
|
||||
Timber.v("%s <<< %s", logId, initialResponse)
|
||||
}
|
||||
|
||||
extractCapabilities(listOf(initialResponse))
|
||||
}
|
||||
|
||||
private fun extractCapabilities(responses: List<ImapResponse>): Boolean {
|
||||
val capabilityResponse = CapabilityResponse.parse(responses) ?: return false
|
||||
val receivedCapabilities = capabilityResponse.capabilities
|
||||
|
||||
Timber.d("Saving %s capabilities for %s", receivedCapabilities, logId)
|
||||
capabilities = receivedCapabilities
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun extractOrRequestCapabilities(responses: List<ImapResponse>) {
|
||||
if (!extractCapabilities(responses)) {
|
||||
Timber.i("Did not get capabilities in post-auth banner, requesting CAPABILITY for %s", logId)
|
||||
requestCapabilities()
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestCapabilitiesIfNecessary() {
|
||||
if (capabilities.isNotEmpty()) return
|
||||
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.i("Did not get capabilities in banner, requesting CAPABILITY for %s", logId)
|
||||
}
|
||||
|
||||
requestCapabilities()
|
||||
}
|
||||
|
||||
private fun requestCapabilities() {
|
||||
val responses = executeSimpleCommand(Commands.CAPABILITY)
|
||||
|
||||
if (!extractCapabilities(responses)) {
|
||||
throw MessagingException("Invalid CAPABILITY response received")
|
||||
}
|
||||
}
|
||||
|
||||
private fun upgradeToTlsIfNecessary() {
|
||||
if (settings.connectionSecurity == ConnectionSecurity.STARTTLS_REQUIRED) {
|
||||
upgradeToTls()
|
||||
}
|
||||
}
|
||||
|
||||
private fun upgradeToTls() {
|
||||
if (!hasCapability(Capabilities.STARTTLS)) {
|
||||
/*
|
||||
* This exception triggers a "Certificate error"
|
||||
* notification that takes the user to the incoming
|
||||
* server settings for review. This might be needed if
|
||||
* the account was configured with an obsolete
|
||||
* "STARTTLS (if available)" setting.
|
||||
*/
|
||||
throw CertificateValidationException("STARTTLS connection security not available")
|
||||
}
|
||||
|
||||
startTls()
|
||||
}
|
||||
|
||||
private fun startTls() {
|
||||
executeSimpleCommand(Commands.STARTTLS)
|
||||
|
||||
val host = settings.host
|
||||
val port = settings.port
|
||||
val clientCertificateAlias = settings.clientCertificateAlias
|
||||
socket = socketFactory.createSocket(socket, host, port, clientCertificateAlias)
|
||||
|
||||
configureSocket()
|
||||
setUpStreamsAndParserFromSocket()
|
||||
|
||||
// Per RFC 2595 (3.1): Once TLS has been started, reissue CAPABILITY command
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.i("Updating capabilities after STARTTLS for %s", logId)
|
||||
}
|
||||
|
||||
requestCapabilities()
|
||||
}
|
||||
|
||||
private fun authenticate(): List<ImapResponse> {
|
||||
return when (settings.authType) {
|
||||
AuthType.XOAUTH2 -> {
|
||||
if (oauthTokenProvider == null) {
|
||||
throw MessagingException("No OAuthToken Provider available.")
|
||||
} else if (!hasCapability(Capabilities.SASL_IR)) {
|
||||
throw MessagingException("SASL-IR capability is missing.")
|
||||
} else if (hasCapability(Capabilities.AUTH_OAUTHBEARER)) {
|
||||
authWithOAuthToken(OAuthMethod.OAUTHBEARER)
|
||||
} else if (hasCapability(Capabilities.AUTH_XOAUTH2)) {
|
||||
authWithOAuthToken(OAuthMethod.XOAUTH2)
|
||||
} else {
|
||||
throw MessagingException("Server doesn't support SASL OAUTHBEARER or XOAUTH2.")
|
||||
}
|
||||
}
|
||||
AuthType.CRAM_MD5 -> {
|
||||
if (hasCapability(Capabilities.AUTH_CRAM_MD5)) {
|
||||
authCramMD5()
|
||||
} else {
|
||||
throw MessagingException("Server doesn't support encrypted passwords using CRAM-MD5.")
|
||||
}
|
||||
}
|
||||
AuthType.PLAIN -> {
|
||||
if (hasCapability(Capabilities.AUTH_PLAIN)) {
|
||||
saslAuthPlainWithLoginFallback()
|
||||
} else if (!hasCapability(Capabilities.LOGINDISABLED)) {
|
||||
login()
|
||||
} else {
|
||||
throw MessagingException(
|
||||
"Server doesn't support unencrypted passwords using AUTH=PLAIN and LOGIN is disabled."
|
||||
)
|
||||
}
|
||||
}
|
||||
AuthType.EXTERNAL -> {
|
||||
if (hasCapability(Capabilities.AUTH_EXTERNAL)) {
|
||||
saslAuthExternal()
|
||||
} else {
|
||||
// Provide notification to user of a problem authenticating using client certificates
|
||||
throw CertificateValidationException(CertificateValidationException.Reason.MissingCapability)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
throw MessagingException("Unhandled authentication method found in the server settings (bug).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun authWithOAuthToken(method: OAuthMethod): List<ImapResponse> {
|
||||
val oauthTokenProvider = checkNotNull(oauthTokenProvider)
|
||||
retryOAuthWithNewToken = true
|
||||
|
||||
return try {
|
||||
attemptOAuth(method)
|
||||
} catch (e: NegativeImapResponseException) {
|
||||
// TODO: Check response code so we don't needlessly invalidate the token.
|
||||
oauthTokenProvider.invalidateToken()
|
||||
|
||||
if (!retryOAuthWithNewToken) {
|
||||
throw handlePermanentOAuthFailure(e)
|
||||
} else {
|
||||
handleTemporaryOAuthFailure(method, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePermanentOAuthFailure(e: NegativeImapResponseException): AuthenticationFailedException {
|
||||
Timber.v(e, "Permanent failure during authentication using OAuth token")
|
||||
|
||||
return AuthenticationFailedException(message = e.message!!, throwable = e, messageFromServer = e.alertText)
|
||||
}
|
||||
|
||||
private fun handleTemporaryOAuthFailure(method: OAuthMethod, e: NegativeImapResponseException): List<ImapResponse> {
|
||||
val oauthTokenProvider = checkNotNull(oauthTokenProvider)
|
||||
|
||||
// We got a response indicating a retry might succeed after token refresh
|
||||
// We could avoid this if we had a reasonable chance of knowing
|
||||
// if a token was invalid before use (e.g. due to expiry). But we don't
|
||||
// This is the intended behaviour per AccountManager
|
||||
Timber.v(e, "Temporary failure - retrying with new token")
|
||||
|
||||
return try {
|
||||
attemptOAuth(method)
|
||||
} catch (e2: NegativeImapResponseException) {
|
||||
// Okay, we failed on a new token.
|
||||
// Invalidate the token anyway but assume it's permanent.
|
||||
Timber.v(e, "Authentication exception for new token, permanent error assumed")
|
||||
|
||||
oauthTokenProvider.invalidateToken()
|
||||
|
||||
throw handlePermanentOAuthFailure(e2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun attemptOAuth(method: OAuthMethod): List<ImapResponse> {
|
||||
val oauthTokenProvider = checkNotNull(oauthTokenProvider)
|
||||
val responseParser = checkNotNull(responseParser)
|
||||
|
||||
val token = oauthTokenProvider.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong())
|
||||
|
||||
val authString = method.buildInitialClientResponse(settings.username, token)
|
||||
val tag = sendSaslIrCommand(method.command, authString, true)
|
||||
|
||||
return responseParser.readStatusResponse(tag, method.command, logId, ::handleOAuthUntaggedResponse)
|
||||
}
|
||||
|
||||
private fun handleOAuthUntaggedResponse(response: ImapResponse) {
|
||||
if (!response.isContinuationRequested) return
|
||||
|
||||
val imapOutputStream = checkNotNull(imapOutputStream)
|
||||
|
||||
if (response.isString(0)) {
|
||||
retryOAuthWithNewToken = XOAuth2ChallengeParser.shouldRetry(response.getString(0), settings.host)
|
||||
}
|
||||
|
||||
imapOutputStream.write('\r'.code)
|
||||
imapOutputStream.write('\n'.code)
|
||||
imapOutputStream.flush()
|
||||
}
|
||||
|
||||
private fun authCramMD5(): List<ImapResponse> {
|
||||
val command = Commands.AUTHENTICATE_CRAM_MD5
|
||||
val tag = sendCommand(command, false)
|
||||
|
||||
val imapOutputStream = checkNotNull(imapOutputStream)
|
||||
val responseParser = checkNotNull(responseParser)
|
||||
|
||||
val response = readContinuationResponse(tag)
|
||||
if (response.size != 1 || !response.isString(0)) {
|
||||
throw MessagingException("Invalid Cram-MD5 nonce received")
|
||||
}
|
||||
|
||||
val b64Nonce = response.getString(0).toByteArray()
|
||||
val b64CRAM = Authentication.computeCramMd5Bytes(settings.username, settings.password, b64Nonce)
|
||||
|
||||
imapOutputStream.write(b64CRAM)
|
||||
imapOutputStream.write('\r'.code)
|
||||
imapOutputStream.write('\n'.code)
|
||||
imapOutputStream.flush()
|
||||
|
||||
return try {
|
||||
responseParser.readStatusResponse(tag, command, logId, null)
|
||||
} catch (e: NegativeImapResponseException) {
|
||||
throw handleAuthenticationFailure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saslAuthPlainWithLoginFallback(): List<ImapResponse> {
|
||||
return try {
|
||||
saslAuthPlain()
|
||||
} catch (e: AuthenticationFailedException) {
|
||||
if (!isConnected) {
|
||||
throw e
|
||||
}
|
||||
|
||||
login()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saslAuthPlain(): List<ImapResponse> {
|
||||
val command = Commands.AUTHENTICATE_PLAIN
|
||||
val tag = sendCommand(command, false)
|
||||
|
||||
val imapOutputStream = checkNotNull(imapOutputStream)
|
||||
val responseParser = checkNotNull(responseParser)
|
||||
|
||||
readContinuationResponse(tag)
|
||||
|
||||
val credentials = "\u0000" + settings.username + "\u0000" + settings.password
|
||||
val encodedCredentials = Base64.encodeBase64(credentials.toByteArray())
|
||||
|
||||
imapOutputStream.write(encodedCredentials)
|
||||
imapOutputStream.write('\r'.code)
|
||||
imapOutputStream.write('\n'.code)
|
||||
imapOutputStream.flush()
|
||||
|
||||
return try {
|
||||
responseParser.readStatusResponse(tag, command, logId, null)
|
||||
} catch (e: NegativeImapResponseException) {
|
||||
throw handleAuthenticationFailure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun login(): List<ImapResponse> {
|
||||
val password = checkNotNull(settings.password)
|
||||
|
||||
/*
|
||||
* Use quoted strings which permit spaces and quotes. (Using IMAP
|
||||
* string literals would be better, but some servers are broken
|
||||
* and don't parse them correctly.)
|
||||
*/
|
||||
|
||||
// escape double-quotes and backslash characters with a backslash
|
||||
val pattern = Pattern.compile("[\\\\\"]")
|
||||
val replacement = "\\\\$0"
|
||||
val encodedUsername = pattern.matcher(settings.username).replaceAll(replacement)
|
||||
val encodedPassword = pattern.matcher(password).replaceAll(replacement)
|
||||
|
||||
return try {
|
||||
val command = String.format(Commands.LOGIN + " \"%s\" \"%s\"", encodedUsername, encodedPassword)
|
||||
executeSimpleCommand(command, true)
|
||||
} catch (e: NegativeImapResponseException) {
|
||||
throw handleAuthenticationFailure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saslAuthExternal(): List<ImapResponse> {
|
||||
return try {
|
||||
val command = Commands.AUTHENTICATE_EXTERNAL + " " + Base64.encode(settings.username)
|
||||
executeSimpleCommand(command, false)
|
||||
} catch (e: NegativeImapResponseException) {
|
||||
/*
|
||||
* Provide notification to the user of a problem authenticating
|
||||
* using client certificates. We don't use an
|
||||
* AuthenticationFailedException because that would trigger a
|
||||
* "Username or password incorrect" notification in
|
||||
* AccountSetupCheckSettings.
|
||||
*/
|
||||
throw CertificateValidationException(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAuthenticationFailure(
|
||||
negativeResponseException: NegativeImapResponseException
|
||||
): MessagingException {
|
||||
val lastResponse = negativeResponseException.lastResponse
|
||||
val responseCode = ResponseCodeExtractor.getResponseCode(lastResponse)
|
||||
|
||||
// If there's no response code we simply assume it was an authentication failure.
|
||||
return if (responseCode == null || responseCode == ResponseCodeExtractor.AUTHENTICATION_FAILED) {
|
||||
if (negativeResponseException.wasByeResponseReceived()) {
|
||||
close()
|
||||
}
|
||||
|
||||
AuthenticationFailedException(negativeResponseException.message!!)
|
||||
} else {
|
||||
close()
|
||||
|
||||
negativeResponseException
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableCompressionIfRequested() {
|
||||
if (hasCapability(Capabilities.COMPRESS_DEFLATE) && settings.useCompression()) {
|
||||
enableCompression()
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableCompression() {
|
||||
try {
|
||||
executeSimpleCommand(Commands.COMPRESS_DEFLATE)
|
||||
} catch (e: NegativeImapResponseException) {
|
||||
Timber.d(e, "Unable to negotiate compression: ")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val socket = checkNotNull(socket)
|
||||
val input = InflaterInputStream(socket.getInputStream(), Inflater(true))
|
||||
val output = ZOutputStream(socket.getOutputStream(), JZlib.Z_BEST_SPEED, true)
|
||||
output.flushMode = JZlib.Z_PARTIAL_FLUSH
|
||||
|
||||
setUpStreamsAndParser(input, output)
|
||||
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.i("Compression enabled for %s", logId)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
close()
|
||||
Timber.e(e, "Error enabling compression")
|
||||
}
|
||||
}
|
||||
|
||||
private fun retrievePathPrefixIfNecessary() {
|
||||
if (settings.pathPrefix != null) return
|
||||
|
||||
if (hasCapability(Capabilities.NAMESPACE)) {
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.i("pathPrefix is unset and server has NAMESPACE capability")
|
||||
}
|
||||
|
||||
handleNamespace()
|
||||
} else {
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.i("pathPrefix is unset but server does not have NAMESPACE capability")
|
||||
}
|
||||
|
||||
settings.pathPrefix = ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNamespace() {
|
||||
val responses = executeSimpleCommand(Commands.NAMESPACE)
|
||||
|
||||
val namespaceResponse = NamespaceResponse.parse(responses) ?: return
|
||||
|
||||
settings.pathPrefix = namespaceResponse.prefix
|
||||
settings.pathDelimiter = namespaceResponse.hierarchyDelimiter
|
||||
settings.setCombinedPrefix(null)
|
||||
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.d("Got path '%s' and separator '%s'", namespaceResponse.prefix, namespaceResponse.hierarchyDelimiter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun retrievePathDelimiterIfNecessary() {
|
||||
if (settings.pathDelimiter == null) {
|
||||
retrievePathDelimiter()
|
||||
}
|
||||
}
|
||||
|
||||
private fun retrievePathDelimiter() {
|
||||
val listResponses = try {
|
||||
executeSimpleCommand(Commands.LIST + " \"\" \"\"")
|
||||
} catch (e: NegativeImapResponseException) {
|
||||
Timber.d(e, "Error getting path delimiter using LIST command")
|
||||
return
|
||||
}
|
||||
|
||||
for (response in listResponses) {
|
||||
if (isListResponse(response)) {
|
||||
val hierarchyDelimiter = response.getString(2)
|
||||
|
||||
settings.pathDelimiter = hierarchyDelimiter
|
||||
settings.setCombinedPrefix(null)
|
||||
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.d("Got path delimiter '%s' for %s", hierarchyDelimiter, logId)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isListResponse(response: ImapResponse): Boolean {
|
||||
if (response.size < 4) return false
|
||||
|
||||
val isListResponse = ImapResponseParser.equalsIgnoreCase(response[0], Responses.LIST)
|
||||
val hierarchyDelimiterValid = response.isString(2)
|
||||
|
||||
return isListResponse && hierarchyDelimiterValid
|
||||
}
|
||||
|
||||
override fun hasCapability(capability: String): Boolean {
|
||||
if (!open) {
|
||||
open()
|
||||
}
|
||||
|
||||
return capabilities.contains(capability.uppercase())
|
||||
}
|
||||
|
||||
private val isCondstoreCapable: Boolean
|
||||
get() = hasCapability(Capabilities.CONDSTORE)
|
||||
|
||||
override val isIdleCapable: Boolean
|
||||
get() {
|
||||
if (K9MailLib.isDebug()) {
|
||||
Timber.v("Connection %s has %d capabilities", logId, capabilities.size)
|
||||
}
|
||||
|
||||
return capabilities.contains(Capabilities.IDLE)
|
||||
}
|
||||
|
||||
override val isUidPlusCapable: Boolean
|
||||
get() = capabilities.contains(Capabilities.UID_PLUS)
|
||||
|
||||
@Synchronized
|
||||
override fun close() {
|
||||
if (!open) return
|
||||
|
||||
open = false
|
||||
|
||||
stacktraceForClose = Exception()
|
||||
|
||||
IOUtils.closeQuietly(inputStream)
|
||||
IOUtils.closeQuietly(imapOutputStream)
|
||||
IOUtils.closeQuietly(socket)
|
||||
|
||||
inputStream = null
|
||||
imapOutputStream = null
|
||||
socket = null
|
||||
}
|
||||
|
||||
override val logId: String
|
||||
get() = "conn" + hashCode()
|
||||
|
||||
@Synchronized
|
||||
@Throws(IOException::class, MessagingException::class)
|
||||
override fun executeSimpleCommand(command: String): List<ImapResponse> {
|
||||
return executeSimpleCommand(command, false)
|
||||
}
|
||||
|
||||
@Throws(IOException::class, MessagingException::class)
|
||||
fun executeSimpleCommand(command: String, sensitive: Boolean): List<ImapResponse> {
|
||||
var commandToLog = command
|
||||
if (sensitive && !K9MailLib.isDebugSensitive()) {
|
||||
commandToLog = "*sensitive*"
|
||||
}
|
||||
|
||||
val tag = sendCommand(command, sensitive)
|
||||
|
||||
val responseParser = checkNotNull(responseParser)
|
||||
return try {
|
||||
responseParser.readStatusResponse(tag, commandToLog, logId, null)
|
||||
} catch (e: IOException) {
|
||||
close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@Throws(IOException::class, MessagingException::class)
|
||||
override fun executeCommandWithIdSet(
|
||||
commandPrefix: String,
|
||||
commandSuffix: String,
|
||||
ids: Set<Long>
|
||||
): List<ImapResponse> {
|
||||
val groupedIds = IdGrouper.groupIds(ids)
|
||||
val splitCommands = ImapCommandSplitter.splitCommand(
|
||||
commandPrefix, commandSuffix, groupedIds, lineLengthLimit
|
||||
)
|
||||
|
||||
return splitCommands.flatMap { splitCommand ->
|
||||
executeSimpleCommand(splitCommand)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class, MessagingException::class)
|
||||
fun sendSaslIrCommand(command: String, initialClientResponse: String, sensitive: Boolean): String {
|
||||
try {
|
||||
open()
|
||||
|
||||
val outputStream = checkNotNull(imapOutputStream)
|
||||
|
||||
val tag = (nextCommandTag++).toString()
|
||||
val commandToSend = "$tag $command $initialClientResponse\r\n"
|
||||
|
||||
outputStream.write(commandToSend.toByteArray())
|
||||
outputStream.flush()
|
||||
|
||||
if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) {
|
||||
if (sensitive && !K9MailLib.isDebugSensitive()) {
|
||||
Timber.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", logId)
|
||||
} else {
|
||||
Timber.v("%s>>> %s %s %s", logId, tag, command, initialClientResponse)
|
||||
}
|
||||
}
|
||||
|
||||
return tag
|
||||
} catch (e: IOException) {
|
||||
close()
|
||||
throw e
|
||||
} catch (e: MessagingException) {
|
||||
close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@Throws(MessagingException::class, IOException::class)
|
||||
override fun sendCommand(command: String, sensitive: Boolean): String {
|
||||
try {
|
||||
open()
|
||||
|
||||
val outputStream = checkNotNull(imapOutputStream)
|
||||
|
||||
val tag = (nextCommandTag++).toString()
|
||||
val commandToSend = "$tag $command\r\n"
|
||||
|
||||
outputStream.write(commandToSend.toByteArray())
|
||||
outputStream.flush()
|
||||
|
||||
if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) {
|
||||
if (sensitive && !K9MailLib.isDebugSensitive()) {
|
||||
Timber.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", logId)
|
||||
} else {
|
||||
Timber.v("%s>>> %s %s", logId, tag, command)
|
||||
}
|
||||
}
|
||||
|
||||
return tag
|
||||
} catch (e: IOException) {
|
||||
close()
|
||||
throw e
|
||||
} catch (e: MessagingException) {
|
||||
close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@Throws(IOException::class)
|
||||
override fun sendContinuation(continuation: String) {
|
||||
val outputStream = checkNotNull(imapOutputStream)
|
||||
|
||||
outputStream.write(continuation.toByteArray())
|
||||
outputStream.write('\r'.code)
|
||||
outputStream.write('\n'.code)
|
||||
outputStream.flush()
|
||||
|
||||
if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) {
|
||||
Timber.v("%s>>> %s", logId, continuation)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun readResponse(): ImapResponse {
|
||||
return readResponse(null)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun readResponse(callback: ImapResponseCallback?): ImapResponse {
|
||||
try {
|
||||
val responseParser = checkNotNull(responseParser)
|
||||
|
||||
val response = responseParser.readResponse(callback)
|
||||
|
||||
if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) {
|
||||
Timber.v("%s<<<%s", logId, response)
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (e: IOException) {
|
||||
close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun readContinuationResponse(tag: String): ImapResponse {
|
||||
var response: ImapResponse
|
||||
do {
|
||||
response = readResponse()
|
||||
|
||||
val responseTag = response.tag
|
||||
if (responseTag != null) {
|
||||
if (responseTag.equals(tag, ignoreCase = true)) {
|
||||
throw MessagingException("Command continuation aborted: $response")
|
||||
} else {
|
||||
Timber.w(
|
||||
"After sending tag %s, got tag response from previous command %s for %s",
|
||||
tag, response, logId
|
||||
)
|
||||
}
|
||||
}
|
||||
} while (!response.isContinuationRequested)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
@get:Throws(IOException::class, MessagingException::class)
|
||||
val lineLengthLimit: Int
|
||||
get() = if (isCondstoreCapable) LENGTH_LIMIT_WITH_CONDSTORE else LENGTH_LIMIT_WITHOUT_CONDSTORE
|
||||
|
||||
private enum class OAuthMethod {
|
||||
XOAUTH2 {
|
||||
override val command: String = Commands.AUTHENTICATE_XOAUTH2
|
||||
|
||||
override fun buildInitialClientResponse(username: String, token: String): String {
|
||||
return Authentication.computeXoauth(username, token)
|
||||
}
|
||||
},
|
||||
OAUTHBEARER {
|
||||
override val command: String = Commands.AUTHENTICATE_OAUTHBEARER
|
||||
|
||||
override fun buildInitialClientResponse(username: String, token: String): String {
|
||||
return buildOAuthBearerInitialClientResponse(username, token)
|
||||
}
|
||||
};
|
||||
|
||||
abstract val command: String
|
||||
abstract fun buildInitialClientResponse(username: String, token: String): String
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BUFFER_SIZE = 1024
|
||||
|
||||
/* The below limits are 20 octets less than the recommended limits, in order to compensate for
|
||||
* the length of the command tag, the space after the tag and the CRLF at the end of the command
|
||||
* (these are not taken into account when calculating the length of the command). For more
|
||||
* information, refer to section 4 of RFC 7162.
|
||||
*
|
||||
* The length limit for servers supporting the CONDSTORE extension is large in order to support
|
||||
* the QRESYNC parameter to the SELECT/EXAMINE commands, which accept a list of known message
|
||||
* sequence numbers as well as their corresponding UIDs.
|
||||
*/
|
||||
private const val LENGTH_LIMIT_WITHOUT_CONDSTORE = 980
|
||||
private const val LENGTH_LIMIT_WITH_CONDSTORE = 8172
|
||||
}
|
||||
}
|
|
@ -1,411 +0,0 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import com.fsck.k9.logging.Timber;
|
||||
import com.fsck.k9.mail.AuthType;
|
||||
import com.fsck.k9.mail.AuthenticationFailedException;
|
||||
import com.fsck.k9.mail.ConnectionSecurity;
|
||||
import com.fsck.k9.mail.Flag;
|
||||
import com.fsck.k9.mail.FolderType;
|
||||
import com.fsck.k9.mail.MessagingException;
|
||||
import com.fsck.k9.mail.ServerSettings;
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider;
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
* TODO Need a default response handler for things like folder updates
|
||||
* </pre>
|
||||
*/
|
||||
class RealImapStore implements ImapStore, ImapConnectionManager, InternalImapStore {
|
||||
private final ImapStoreConfig config;
|
||||
private final TrustedSocketFactory trustedSocketFactory;
|
||||
private Set<Flag> permanentFlagsIndex = EnumSet.noneOf(Flag.class);
|
||||
private OAuth2TokenProvider oauthTokenProvider;
|
||||
|
||||
private String host;
|
||||
private int port;
|
||||
private String username;
|
||||
private String password;
|
||||
private String clientCertificateAlias;
|
||||
private ConnectionSecurity connectionSecurity;
|
||||
private AuthType authType;
|
||||
private String pathPrefix;
|
||||
private String combinedPrefix = null;
|
||||
private String pathDelimiter = null;
|
||||
private final Deque<ImapConnection> connections = new LinkedList<>();
|
||||
private FolderNameCodec folderNameCodec;
|
||||
private volatile int connectionGeneration = 1;
|
||||
|
||||
|
||||
public RealImapStore(ServerSettings serverSettings, ImapStoreConfig config,
|
||||
TrustedSocketFactory trustedSocketFactory, OAuth2TokenProvider oauthTokenProvider) {
|
||||
this.config = config;
|
||||
this.trustedSocketFactory = trustedSocketFactory;
|
||||
|
||||
host = serverSettings.host;
|
||||
port = serverSettings.port;
|
||||
|
||||
connectionSecurity = serverSettings.connectionSecurity;
|
||||
this.oauthTokenProvider = oauthTokenProvider;
|
||||
|
||||
authType = serverSettings.authenticationType;
|
||||
username = serverSettings.username;
|
||||
password = serverSettings.password;
|
||||
clientCertificateAlias = serverSettings.clientCertificateAlias;
|
||||
|
||||
boolean autoDetectNamespace = ImapStoreSettings.getAutoDetectNamespace(serverSettings);
|
||||
String pathPrefixSetting = ImapStoreSettings.getPathPrefix(serverSettings);
|
||||
|
||||
// Make extra sure pathPrefix is null if "auto-detect namespace" is configured
|
||||
pathPrefix = autoDetectNamespace ? null : pathPrefixSetting;
|
||||
|
||||
folderNameCodec = FolderNameCodec.newInstance();
|
||||
}
|
||||
|
||||
public ImapFolder getFolder(String name) {
|
||||
return new RealImapFolder(this, this, name, folderNameCodec);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public String getCombinedPrefix() {
|
||||
if (combinedPrefix == null) {
|
||||
if (pathPrefix != null) {
|
||||
String tmpPrefix = pathPrefix.trim();
|
||||
String tmpDelim = (pathDelimiter != null ? pathDelimiter.trim() : "");
|
||||
if (tmpPrefix.endsWith(tmpDelim)) {
|
||||
combinedPrefix = tmpPrefix;
|
||||
} else if (tmpPrefix.length() > 0) {
|
||||
combinedPrefix = tmpPrefix + tmpDelim;
|
||||
} else {
|
||||
combinedPrefix = "";
|
||||
}
|
||||
} else {
|
||||
combinedPrefix = "";
|
||||
}
|
||||
}
|
||||
|
||||
return combinedPrefix;
|
||||
}
|
||||
|
||||
public List<FolderListItem> getFolders() throws MessagingException {
|
||||
ImapConnection connection = getConnection();
|
||||
|
||||
try {
|
||||
List<FolderListItem> folders = listFolders(connection, false);
|
||||
|
||||
if (!config.isSubscribedFoldersOnly()) {
|
||||
return folders;
|
||||
}
|
||||
|
||||
List<FolderListItem> subscribedFolders = listFolders(connection, true);
|
||||
return limitToSubscribedFolders(folders, subscribedFolders);
|
||||
} catch (AuthenticationFailedException e) {
|
||||
connection.close();
|
||||
throw e;
|
||||
} catch (IOException | MessagingException ioe) {
|
||||
connection.close();
|
||||
throw new MessagingException("Unable to get folder list.", ioe);
|
||||
} finally {
|
||||
releaseConnection(connection);
|
||||
}
|
||||
}
|
||||
|
||||
private List<FolderListItem> limitToSubscribedFolders(List<FolderListItem> folders,
|
||||
List<FolderListItem> subscribedFolders) {
|
||||
Set<String> subscribedFolderNames = new HashSet<>(subscribedFolders.size());
|
||||
for (FolderListItem subscribedFolder : subscribedFolders) {
|
||||
subscribedFolderNames.add(subscribedFolder.getServerId());
|
||||
}
|
||||
|
||||
List<FolderListItem> filteredFolders = new ArrayList<>();
|
||||
for (FolderListItem folder : folders) {
|
||||
if (subscribedFolderNames.contains(folder.getServerId())) {
|
||||
filteredFolders.add(folder);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredFolders;
|
||||
}
|
||||
|
||||
private List<FolderListItem> listFolders(ImapConnection connection, boolean subscribedOnly) throws IOException,
|
||||
MessagingException {
|
||||
|
||||
String commandFormat;
|
||||
if (subscribedOnly) {
|
||||
commandFormat = "LSUB \"\" %s";
|
||||
} else if (connection.hasCapability(Capabilities.SPECIAL_USE) &&
|
||||
connection.hasCapability(Capabilities.LIST_EXTENDED)) {
|
||||
commandFormat = "LIST \"\" %s RETURN (SPECIAL-USE)";
|
||||
} else {
|
||||
commandFormat = "LIST \"\" %s";
|
||||
}
|
||||
|
||||
String encodedListPrefix = ImapUtility.encodeString(getCombinedPrefix() + "*");
|
||||
List<ImapResponse> responses = connection.executeSimpleCommand(String.format(commandFormat, encodedListPrefix));
|
||||
|
||||
List<ListResponse> listResponses = (subscribedOnly) ?
|
||||
ListResponse.parseLsub(responses) :
|
||||
ListResponse.parseList(responses);
|
||||
|
||||
Map<String, FolderListItem> folderMap = new HashMap<>(listResponses.size());
|
||||
for (ListResponse listResponse : listResponses) {
|
||||
String serverId = listResponse.getName();
|
||||
|
||||
if (pathDelimiter == null) {
|
||||
pathDelimiter = listResponse.getHierarchyDelimiter();
|
||||
combinedPrefix = null;
|
||||
}
|
||||
|
||||
if (RealImapFolder.INBOX.equalsIgnoreCase(serverId)) {
|
||||
continue;
|
||||
} else if (listResponse.hasAttribute("\\NoSelect")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String name = getFolderDisplayName(serverId);
|
||||
String oldServerId = getOldServerId(serverId);
|
||||
|
||||
FolderType type;
|
||||
if (listResponse.hasAttribute("\\Archive") || listResponse.hasAttribute("\\All")) {
|
||||
type = FolderType.ARCHIVE;
|
||||
} else if (listResponse.hasAttribute("\\Drafts")) {
|
||||
type = FolderType.DRAFTS;
|
||||
} else if (listResponse.hasAttribute("\\Sent")) {
|
||||
type = FolderType.SENT;
|
||||
} else if (listResponse.hasAttribute("\\Junk")) {
|
||||
type = FolderType.SPAM;
|
||||
} else if (listResponse.hasAttribute("\\Trash")) {
|
||||
type = FolderType.TRASH;
|
||||
} else {
|
||||
type = FolderType.REGULAR;
|
||||
}
|
||||
|
||||
FolderListItem existingItem = folderMap.get(serverId);
|
||||
if (existingItem == null || existingItem.getType() == FolderType.REGULAR) {
|
||||
folderMap.put(serverId, new FolderListItem(serverId, name, type, oldServerId));
|
||||
}
|
||||
}
|
||||
|
||||
List<FolderListItem> folders = new ArrayList<>(folderMap.size() + 1);
|
||||
folders.add(new FolderListItem(RealImapFolder.INBOX, RealImapFolder.INBOX, FolderType.INBOX, RealImapFolder.INBOX));
|
||||
folders.addAll(folderMap.values());
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
private String getFolderDisplayName(String serverId) {
|
||||
String decodedFolderName;
|
||||
try {
|
||||
decodedFolderName = folderNameCodec.decode(serverId);
|
||||
} catch (CharacterCodingException e) {
|
||||
Timber.w(e, "Folder name not correctly encoded with the UTF-7 variant as defined by RFC 3501: %s",
|
||||
serverId);
|
||||
|
||||
decodedFolderName = serverId;
|
||||
}
|
||||
|
||||
String folderNameWithoutPrefix = removePrefixFromFolderName(decodedFolderName);
|
||||
return folderNameWithoutPrefix != null ? folderNameWithoutPrefix : decodedFolderName;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String getOldServerId(String serverId) {
|
||||
String decodedFolderName;
|
||||
try {
|
||||
decodedFolderName = folderNameCodec.decode(serverId);
|
||||
} catch (CharacterCodingException e) {
|
||||
// Previous versions of K-9 Mail ignored folders with invalid UTF-7 encoding
|
||||
return null;
|
||||
}
|
||||
|
||||
return removePrefixFromFolderName(decodedFolderName);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String removePrefixFromFolderName(String folderName) {
|
||||
String prefix = getCombinedPrefix();
|
||||
int prefixLength = prefix.length();
|
||||
if (prefixLength == 0) {
|
||||
return folderName;
|
||||
}
|
||||
|
||||
if (!folderName.startsWith(prefix)) {
|
||||
// Folder name doesn't start with our configured prefix. But right now when building commands we prefix all
|
||||
// folders except the INBOX with the prefix. So we won't be able to use this folder.
|
||||
return null;
|
||||
}
|
||||
|
||||
return folderName.substring(prefixLength);
|
||||
}
|
||||
|
||||
public void checkSettings() throws MessagingException {
|
||||
try {
|
||||
ImapConnection connection = createImapConnection();
|
||||
|
||||
connection.open();
|
||||
connection.close();
|
||||
} catch (IOException ioe) {
|
||||
throw new MessagingException("Unable to connect", ioe);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public ImapConnection getConnection() throws MessagingException {
|
||||
ImapConnection connection;
|
||||
while ((connection = pollConnection()) != null) {
|
||||
try {
|
||||
connection.executeSimpleCommand(Commands.NOOP);
|
||||
break;
|
||||
} catch (IOException ioe) {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (connection == null) {
|
||||
connection = createImapConnection();
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
private ImapConnection pollConnection() {
|
||||
synchronized (connections) {
|
||||
return connections.poll();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void releaseConnection(ImapConnection connection) {
|
||||
if (connection != null && connection.isConnected()) {
|
||||
if (connection.getConnectionGeneration() == connectionGeneration) {
|
||||
synchronized (connections) {
|
||||
connections.offer(connection);
|
||||
}
|
||||
} else {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closeAllConnections() {
|
||||
Timber.v("ImapStore.closeAllConnections()");
|
||||
|
||||
List<ImapConnection> connectionsToClose;
|
||||
synchronized (connections) {
|
||||
connectionGeneration++;
|
||||
connectionsToClose = new ArrayList<>(connections);
|
||||
connections.clear();
|
||||
}
|
||||
|
||||
for (ImapConnection connection : connectionsToClose) {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
ImapConnection createImapConnection() {
|
||||
return new RealImapConnection(
|
||||
new StoreImapSettings(),
|
||||
trustedSocketFactory,
|
||||
oauthTokenProvider,
|
||||
connectionGeneration);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public String getLogLabel() {
|
||||
return config.getLogLabel();
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public Set<Flag> getPermanentFlagsIndex() {
|
||||
return permanentFlagsIndex;
|
||||
}
|
||||
|
||||
|
||||
private class StoreImapSettings implements ImapSettings {
|
||||
@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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean useCompression() {
|
||||
return config.useCompression();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPathPrefix() {
|
||||
return pathPrefix;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPathPrefix(String prefix) {
|
||||
pathPrefix = prefix;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPathDelimiter() {
|
||||
return pathDelimiter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPathDelimiter(String delimiter) {
|
||||
pathDelimiter = delimiter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCombinedPrefix(String prefix) {
|
||||
combinedPrefix = prefix;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,322 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.logging.Timber
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.MessagingException
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.autoDetectNamespace
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.pathPrefix
|
||||
import java.io.IOException
|
||||
import java.util.Deque
|
||||
import java.util.LinkedList
|
||||
|
||||
internal open class RealImapStore(
|
||||
private val serverSettings: ServerSettings,
|
||||
private val config: ImapStoreConfig,
|
||||
private val trustedSocketFactory: TrustedSocketFactory,
|
||||
private val oauthTokenProvider: OAuth2TokenProvider?
|
||||
) : ImapStore, ImapConnectionManager, InternalImapStore {
|
||||
private val folderNameCodec: FolderNameCodec = FolderNameCodec.newInstance()
|
||||
|
||||
private val host: String = checkNotNull(serverSettings.host)
|
||||
|
||||
private var pathPrefix: String?
|
||||
private var combinedPrefix: String? = null
|
||||
private var pathDelimiter: String? = null
|
||||
|
||||
private val permanentFlagsIndex: MutableSet<Flag> = mutableSetOf()
|
||||
private val connections: Deque<ImapConnection> = LinkedList()
|
||||
|
||||
@Volatile
|
||||
private var connectionGeneration = 1
|
||||
|
||||
init {
|
||||
val autoDetectNamespace = serverSettings.autoDetectNamespace
|
||||
val pathPrefixSetting = serverSettings.pathPrefix
|
||||
|
||||
// Make extra sure pathPrefix is null if "auto-detect namespace" is configured
|
||||
pathPrefix = if (autoDetectNamespace) null else pathPrefixSetting
|
||||
}
|
||||
|
||||
override fun getFolder(name: String): ImapFolder {
|
||||
return RealImapFolder(
|
||||
internalImapStore = this,
|
||||
connectionManager = this,
|
||||
serverId = name,
|
||||
folderNameCodec = folderNameCodec
|
||||
)
|
||||
}
|
||||
|
||||
override fun getCombinedPrefix(): String {
|
||||
return combinedPrefix ?: buildCombinedPrefix().also { combinedPrefix = it }
|
||||
}
|
||||
|
||||
private fun buildCombinedPrefix(): String {
|
||||
val pathPrefix = pathPrefix ?: return ""
|
||||
|
||||
val trimmedPathPrefix = pathPrefix.trim { it <= ' ' }
|
||||
val trimmedPathDelimiter = pathDelimiter?.trim { it <= ' ' }.orEmpty()
|
||||
|
||||
return if (trimmedPathPrefix.endsWith(trimmedPathDelimiter)) {
|
||||
trimmedPathPrefix
|
||||
} else if (trimmedPathPrefix.isNotEmpty()) {
|
||||
trimmedPathPrefix + trimmedPathDelimiter
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
override fun getFolders(): List<FolderListItem> {
|
||||
val connection = getConnection()
|
||||
|
||||
return try {
|
||||
val folders = listFolders(connection, false)
|
||||
if (!config.isSubscribedFoldersOnly()) {
|
||||
return folders
|
||||
}
|
||||
|
||||
val subscribedFolders = listFolders(connection, true)
|
||||
limitToSubscribedFolders(folders, subscribedFolders)
|
||||
} catch (e: AuthenticationFailedException) {
|
||||
connection.close()
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
connection.close()
|
||||
throw MessagingException("Unable to get folder list.", e)
|
||||
} catch (e: MessagingException) {
|
||||
connection.close()
|
||||
throw MessagingException("Unable to get folder list.", e)
|
||||
} finally {
|
||||
releaseConnection(connection)
|
||||
}
|
||||
}
|
||||
|
||||
private fun limitToSubscribedFolders(
|
||||
folders: List<FolderListItem>,
|
||||
subscribedFolders: List<FolderListItem>
|
||||
): List<FolderListItem> {
|
||||
val subscribedFolderServerIds = subscribedFolders.map { it.serverId }.toSet()
|
||||
return folders.filter { it.serverId in subscribedFolderServerIds }
|
||||
}
|
||||
|
||||
@Throws(IOException::class, MessagingException::class)
|
||||
private fun listFolders(connection: ImapConnection, subscribedOnly: Boolean): List<FolderListItem> {
|
||||
val commandFormat = when {
|
||||
subscribedOnly -> {
|
||||
"LSUB \"\" %s"
|
||||
}
|
||||
connection.supportsListExtended -> {
|
||||
"LIST \"\" %s RETURN (SPECIAL-USE)"
|
||||
}
|
||||
else -> {
|
||||
"LIST \"\" %s"
|
||||
}
|
||||
}
|
||||
|
||||
val encodedListPrefix = ImapUtility.encodeString(getCombinedPrefix() + "*")
|
||||
val responses = connection.executeSimpleCommand(String.format(commandFormat, encodedListPrefix))
|
||||
|
||||
val listResponses = if (subscribedOnly) {
|
||||
ListResponse.parseLsub(responses)
|
||||
} else {
|
||||
ListResponse.parseList(responses)
|
||||
}
|
||||
|
||||
val folderMap = mutableMapOf<String, FolderListItem>()
|
||||
for (listResponse in listResponses) {
|
||||
val serverId = listResponse.name
|
||||
|
||||
if (pathDelimiter == null) {
|
||||
pathDelimiter = listResponse.hierarchyDelimiter
|
||||
combinedPrefix = null
|
||||
}
|
||||
|
||||
if (RealImapFolder.INBOX.equals(serverId, ignoreCase = true)) {
|
||||
continue
|
||||
} else if (listResponse.hasAttribute("\\NoSelect")) {
|
||||
continue
|
||||
}
|
||||
|
||||
val name = getFolderDisplayName(serverId)
|
||||
val oldServerId = getOldServerId(serverId)
|
||||
|
||||
val type = when {
|
||||
listResponse.hasAttribute("\\Archive") -> FolderType.ARCHIVE
|
||||
listResponse.hasAttribute("\\All") -> FolderType.ARCHIVE
|
||||
listResponse.hasAttribute("\\Drafts") -> FolderType.DRAFTS
|
||||
listResponse.hasAttribute("\\Sent") -> FolderType.SENT
|
||||
listResponse.hasAttribute("\\Junk") -> FolderType.SPAM
|
||||
listResponse.hasAttribute("\\Trash") -> FolderType.TRASH
|
||||
else -> FolderType.REGULAR
|
||||
}
|
||||
|
||||
val existingItem = folderMap[serverId]
|
||||
if (existingItem == null || existingItem.type == FolderType.REGULAR) {
|
||||
folderMap[serverId] = FolderListItem(serverId, name, type, oldServerId)
|
||||
}
|
||||
}
|
||||
|
||||
return buildList {
|
||||
add(FolderListItem(RealImapFolder.INBOX, RealImapFolder.INBOX, FolderType.INBOX, RealImapFolder.INBOX))
|
||||
addAll(folderMap.values)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFolderDisplayName(serverId: String): String {
|
||||
val decodedFolderName = try {
|
||||
folderNameCodec.decode(serverId)
|
||||
} catch (e: CharacterCodingException) {
|
||||
Timber.w(e, "Folder name not correctly encoded with the UTF-7 variant as defined by RFC 3501: %s", serverId)
|
||||
serverId
|
||||
}
|
||||
|
||||
val folderNameWithoutPrefix = removePrefixFromFolderName(decodedFolderName)
|
||||
return folderNameWithoutPrefix ?: decodedFolderName
|
||||
}
|
||||
|
||||
private fun getOldServerId(serverId: String): String? {
|
||||
val decodedFolderName = try {
|
||||
folderNameCodec.decode(serverId)
|
||||
} catch (e: CharacterCodingException) {
|
||||
// Previous versions of K-9 Mail ignored folders with invalid UTF-7 encoding
|
||||
return null
|
||||
}
|
||||
|
||||
return removePrefixFromFolderName(decodedFolderName)
|
||||
}
|
||||
|
||||
private fun removePrefixFromFolderName(folderName: String): String? {
|
||||
val prefix = getCombinedPrefix()
|
||||
val prefixLength = prefix.length
|
||||
if (prefixLength == 0) {
|
||||
return folderName
|
||||
}
|
||||
|
||||
if (!folderName.startsWith(prefix)) {
|
||||
// Folder name doesn't start with our configured prefix. But right now when building commands we prefix all
|
||||
// folders except the INBOX with the prefix. So we won't be able to use this folder.
|
||||
return null
|
||||
}
|
||||
|
||||
return folderName.substring(prefixLength)
|
||||
}
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
override fun checkSettings() {
|
||||
try {
|
||||
val connection = createImapConnection()
|
||||
|
||||
connection.open()
|
||||
connection.close()
|
||||
} catch (e: IOException) {
|
||||
throw MessagingException("Unable to connect", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
override fun getConnection(): ImapConnection {
|
||||
while (true) {
|
||||
val connection = pollConnection() ?: return createImapConnection()
|
||||
|
||||
try {
|
||||
connection.executeSimpleCommand(Commands.NOOP)
|
||||
|
||||
// If the command completes without an error this connection is still usable.
|
||||
return connection
|
||||
} catch (ioe: IOException) {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pollConnection(): ImapConnection? {
|
||||
return synchronized(connections) {
|
||||
connections.poll()
|
||||
}
|
||||
}
|
||||
|
||||
override fun releaseConnection(connection: ImapConnection?) {
|
||||
if (connection != null && connection.isConnected) {
|
||||
if (connection.connectionGeneration == connectionGeneration) {
|
||||
synchronized(connections) {
|
||||
connections.offer(connection)
|
||||
}
|
||||
} else {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun closeAllConnections() {
|
||||
Timber.v("ImapStore.closeAllConnections()")
|
||||
|
||||
val connectionsToClose = synchronized(connections) {
|
||||
val connectionsToClose = connections.toList()
|
||||
|
||||
connectionGeneration++
|
||||
connections.clear()
|
||||
|
||||
connectionsToClose
|
||||
}
|
||||
|
||||
for (connection in connectionsToClose) {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
open fun createImapConnection(): ImapConnection {
|
||||
return RealImapConnection(
|
||||
StoreImapSettings(),
|
||||
trustedSocketFactory,
|
||||
oauthTokenProvider,
|
||||
connectionGeneration
|
||||
)
|
||||
}
|
||||
|
||||
override val logLabel: String
|
||||
get() = config.logLabel
|
||||
|
||||
override fun getPermanentFlagsIndex(): MutableSet<Flag> {
|
||||
return permanentFlagsIndex
|
||||
}
|
||||
|
||||
private inner class StoreImapSettings : ImapSettings {
|
||||
override val host: String = this@RealImapStore.host
|
||||
override val port: Int = serverSettings.port
|
||||
override val connectionSecurity: ConnectionSecurity = serverSettings.connectionSecurity
|
||||
override val authType: AuthType = serverSettings.authenticationType
|
||||
override val username: String = serverSettings.username
|
||||
override val password: String? = serverSettings.password
|
||||
override val clientCertificateAlias: String? = serverSettings.clientCertificateAlias
|
||||
|
||||
override fun useCompression(): Boolean {
|
||||
return this@RealImapStore.config.useCompression()
|
||||
}
|
||||
|
||||
override var pathPrefix: String?
|
||||
get() = this@RealImapStore.pathPrefix
|
||||
set(value) {
|
||||
this@RealImapStore.pathPrefix = value
|
||||
}
|
||||
|
||||
override var pathDelimiter: String?
|
||||
get() = this@RealImapStore.pathDelimiter
|
||||
set(value) {
|
||||
this@RealImapStore.pathDelimiter = value
|
||||
}
|
||||
|
||||
override fun setCombinedPrefix(prefix: String?) {
|
||||
combinedPrefix = prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val ImapConnection.supportsListExtended: Boolean
|
||||
get() = hasCapability(Capabilities.SPECIAL_USE) && hasCapability(Capabilities.LIST_EXTENDED)
|
|
@ -38,10 +38,6 @@ private val OAUTHBEARER_STRING = "n,a=$USERNAME,\u0001auth=Bearer $XOAUTH_TOKEN\
|
|||
class RealImapConnectionTest {
|
||||
private var socketFactory = TestTrustedSocketFactory.newInstance()
|
||||
private var oAuth2TokenProvider = TestTokenProvider()
|
||||
private var settings = SimpleImapSettings().apply {
|
||||
username = USERNAME
|
||||
password = PASSWORD
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
|
@ -646,8 +642,7 @@ class RealImapConnectionTest {
|
|||
|
||||
@Test
|
||||
fun `open() with connection error should throw`() {
|
||||
settings.host = "127.1.2.3"
|
||||
settings.port = 143
|
||||
val settings = createImapSettings(host = "127.1.2.3")
|
||||
val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider)
|
||||
|
||||
try {
|
||||
|
@ -661,8 +656,7 @@ class RealImapConnectionTest {
|
|||
|
||||
@Test
|
||||
fun `open() with invalid hostname should throw`() {
|
||||
settings.host = "host name"
|
||||
settings.port = 143
|
||||
val settings = createImapSettings(host = "host name")
|
||||
val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider)
|
||||
|
||||
try {
|
||||
|
@ -676,7 +670,6 @@ class RealImapConnectionTest {
|
|||
|
||||
@Test
|
||||
fun `open() with STARTTLS capability should issue STARTTLS command`() {
|
||||
settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED
|
||||
val server = MockImapServer().apply {
|
||||
preAuthenticationDialog(capabilities = "STARTTLS LOGINDISABLED")
|
||||
expect("2 STARTTLS")
|
||||
|
@ -691,7 +684,11 @@ class RealImapConnectionTest {
|
|||
output("* NAMESPACE ((\"\" \"/\")) NIL NIL")
|
||||
output("5 OK command completed")
|
||||
}
|
||||
val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
|
||||
val imapConnection = startServerAndCreateImapConnection(
|
||||
server,
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
authType = AuthType.PLAIN
|
||||
)
|
||||
|
||||
imapConnection.open()
|
||||
|
||||
|
@ -701,11 +698,13 @@ class RealImapConnectionTest {
|
|||
|
||||
@Test
|
||||
fun `open() with STARTTLS but without STARTTLS capability should throw`() {
|
||||
settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED
|
||||
val server = MockImapServer().apply {
|
||||
preAuthenticationDialog()
|
||||
}
|
||||
val imapConnection = startServerAndCreateImapConnection(server)
|
||||
val imapConnection = startServerAndCreateImapConnection(
|
||||
server,
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
)
|
||||
|
||||
try {
|
||||
imapConnection.open()
|
||||
|
@ -721,7 +720,6 @@ class RealImapConnectionTest {
|
|||
|
||||
@Test
|
||||
fun `open() with untagged CAPABILITY after STARTTLS should not throw`() {
|
||||
settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED
|
||||
val server = MockImapServer().apply {
|
||||
preAuthenticationDialog(capabilities = "STARTTLS LOGINDISABLED")
|
||||
expect("2 STARTTLS")
|
||||
|
@ -735,7 +733,11 @@ class RealImapConnectionTest {
|
|||
output("4 OK [CAPABILITY IMAP4REV1] LOGIN completed")
|
||||
simplePostAuthenticationDialog(tag = 5)
|
||||
}
|
||||
val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
|
||||
val imapConnection = startServerAndCreateImapConnection(
|
||||
server,
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
authType = AuthType.PLAIN
|
||||
)
|
||||
|
||||
imapConnection.open()
|
||||
|
||||
|
@ -745,13 +747,16 @@ class RealImapConnectionTest {
|
|||
|
||||
@Test
|
||||
fun `open() with negative response to STARTTLS command should throw`() {
|
||||
settings.connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED
|
||||
val server = MockImapServer().apply {
|
||||
preAuthenticationDialog(capabilities = "STARTTLS")
|
||||
expect("2 STARTTLS")
|
||||
output("2 NO")
|
||||
}
|
||||
val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.PLAIN)
|
||||
val imapConnection = startServerAndCreateImapConnection(
|
||||
server,
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
authType = AuthType.PLAIN
|
||||
)
|
||||
|
||||
try {
|
||||
imapConnection.open()
|
||||
|
@ -766,7 +771,6 @@ class RealImapConnectionTest {
|
|||
|
||||
@Test
|
||||
fun `open() with COMPRESS=DEFLATE capability should enable compression`() {
|
||||
settings.setUseCompression(true)
|
||||
val server = MockImapServer().apply {
|
||||
simplePreAuthAndLoginDialog(postAuthCapabilities = "COMPRESS=DEFLATE")
|
||||
expect("3 COMPRESS DEFLATE")
|
||||
|
@ -774,7 +778,7 @@ class RealImapConnectionTest {
|
|||
enableCompression()
|
||||
simplePostAuthenticationDialog(tag = 4)
|
||||
}
|
||||
val imapConnection = startServerAndCreateImapConnection(server)
|
||||
val imapConnection = startServerAndCreateImapConnection(server, useCompression = true)
|
||||
|
||||
imapConnection.open()
|
||||
|
||||
|
@ -784,14 +788,13 @@ class RealImapConnectionTest {
|
|||
|
||||
@Test
|
||||
fun `open() with negative response to COMPRESS command should continue`() {
|
||||
settings.setUseCompression(true)
|
||||
val server = MockImapServer().apply {
|
||||
simplePreAuthAndLoginDialog(postAuthCapabilities = "COMPRESS=DEFLATE")
|
||||
expect("3 COMPRESS DEFLATE")
|
||||
output("3 NO")
|
||||
simplePostAuthenticationDialog(tag = 4)
|
||||
}
|
||||
val imapConnection = startServerAndCreateImapConnection(server)
|
||||
val imapConnection = startServerAndCreateImapConnection(server, useCompression = true)
|
||||
|
||||
imapConnection.open()
|
||||
|
||||
|
@ -801,13 +804,12 @@ class RealImapConnectionTest {
|
|||
|
||||
@Test
|
||||
fun `open() with IOException during COMPRESS command should throw`() {
|
||||
settings.setUseCompression(true)
|
||||
val server = MockImapServer().apply {
|
||||
simplePreAuthAndLoginDialog(postAuthCapabilities = "COMPRESS=DEFLATE")
|
||||
expect("3 COMPRESS DEFLATE")
|
||||
closeConnection()
|
||||
}
|
||||
val imapConnection = startServerAndCreateImapConnection(server)
|
||||
val imapConnection = startServerAndCreateImapConnection(server, useCompression = true)
|
||||
|
||||
try {
|
||||
imapConnection.open()
|
||||
|
@ -855,6 +857,7 @@ class RealImapConnectionTest {
|
|||
|
||||
@Test
|
||||
fun `isConnected without previous open() should return false`() {
|
||||
val settings = createImapSettings()
|
||||
val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider)
|
||||
|
||||
val result = imapConnection.isConnected
|
||||
|
@ -889,6 +892,7 @@ class RealImapConnectionTest {
|
|||
|
||||
@Test
|
||||
fun `close() without open() should not throw`() {
|
||||
val settings = createImapSettings()
|
||||
val imapConnection = createImapConnection(settings, socketFactory, oAuth2TokenProvider)
|
||||
|
||||
imapConnection.close()
|
||||
|
@ -1007,20 +1011,29 @@ class RealImapConnectionTest {
|
|||
settings,
|
||||
socketFactory,
|
||||
oAuth2TokenProvider,
|
||||
connectionGeneration,
|
||||
SOCKET_CONNECT_TIMEOUT,
|
||||
SOCKET_READ_TIMEOUT,
|
||||
connectionGeneration
|
||||
SOCKET_READ_TIMEOUT
|
||||
)
|
||||
}
|
||||
|
||||
private fun startServerAndCreateImapConnection(
|
||||
server: MockImapServer,
|
||||
authType: AuthType = AuthType.PLAIN
|
||||
connectionSecurity: ConnectionSecurity = ConnectionSecurity.NONE,
|
||||
authType: AuthType = AuthType.PLAIN,
|
||||
useCompression: Boolean = false
|
||||
): ImapConnection {
|
||||
server.start()
|
||||
settings.host = server.host
|
||||
settings.port = server.port
|
||||
settings.authType = authType
|
||||
|
||||
val settings = SimpleImapSettings(
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = connectionSecurity,
|
||||
authType = authType,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
useCompression = useCompression
|
||||
)
|
||||
|
||||
return createImapConnection(settings, socketFactory, oAuth2TokenProvider)
|
||||
}
|
||||
|
@ -1068,11 +1081,19 @@ class RealImapConnectionTest {
|
|||
}
|
||||
|
||||
private fun MockImapServer.simplePreAuthAndLoginDialog(postAuthCapabilities: String = "") {
|
||||
settings.authType = AuthType.PLAIN
|
||||
preAuthenticationDialog()
|
||||
expect("2 LOGIN \"$USERNAME\" \"$PASSWORD\"")
|
||||
output("2 OK [CAPABILITY $postAuthCapabilities] LOGIN completed")
|
||||
}
|
||||
|
||||
private fun createImapSettings(host: String = "irrelevant"): ImapSettings {
|
||||
return SimpleImapSettings(
|
||||
host = host,
|
||||
port = 143,
|
||||
authType = AuthType.PLAIN,
|
||||
username = "irrelevant"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class TestTokenProvider : OAuth2TokenProvider {
|
||||
|
|
|
@ -29,7 +29,6 @@ import org.junit.Before
|
|||
import org.junit.Test
|
||||
import org.mockito.ArgumentMatchers.anySet
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.ArgumentMatchers.eq
|
||||
import org.mockito.ArgumentMatchers.startsWith
|
||||
import org.mockito.Mockito.atLeastOnce
|
||||
import org.mockito.Mockito.times
|
||||
|
@ -39,6 +38,7 @@ import org.mockito.kotlin.anyOrNull
|
|||
import org.mockito.kotlin.argumentCaptor
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.doThrow
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.whenever
|
||||
|
||||
|
@ -48,7 +48,7 @@ class RealImapFolderTest {
|
|||
override fun getCombinedPrefix() = ""
|
||||
override fun getPermanentFlagsIndex() = mutableSetOf<Flag>()
|
||||
}
|
||||
private val imapConnection = mock<RealImapConnection>()
|
||||
private val imapConnection = mock<ImapConnection>()
|
||||
private val testConnectionManager = TestConnectionManager(imapConnection)
|
||||
|
||||
private lateinit var tempDirectory: File
|
||||
|
|
|
@ -1,489 +0,0 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import com.fsck.k9.mail.AuthType;
|
||||
import com.fsck.k9.mail.ConnectionSecurity;
|
||||
import com.fsck.k9.mail.FolderType;
|
||||
import com.fsck.k9.mail.MessagingException;
|
||||
import com.fsck.k9.mail.ServerSettings;
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider;
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.internal.util.collections.Sets;
|
||||
|
||||
import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.fail;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
|
||||
public class RealImapStoreTest {
|
||||
private ImapStoreConfig config = mock(ImapStoreConfig.class);
|
||||
private TestImapStore imapStore;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
ServerSettings serverSettings = createServerSettings();
|
||||
TrustedSocketFactory trustedSocketFactory = mock(TrustedSocketFactory.class);
|
||||
OAuth2TokenProvider oauth2TokenProvider = mock(OAuth2TokenProvider.class);
|
||||
|
||||
imapStore = new TestImapStore(serverSettings, config, trustedSocketFactory,
|
||||
oauth2TokenProvider);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkSettings_shouldCreateImapConnectionAndCallOpen() throws Exception {
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
|
||||
imapStore.checkSettings();
|
||||
|
||||
verify(imapConnection).open();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkSettings_withOpenThrowing_shouldThrowMessagingException() throws Exception {
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
doThrow(IOException.class).when(imapConnection).open();
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
|
||||
try {
|
||||
imapStore.checkSettings();
|
||||
fail("Expected exception");
|
||||
} catch (MessagingException e) {
|
||||
assertEquals("Unable to connect", e.getMessage());
|
||||
assertNotNull(e.getCause());
|
||||
assertEquals(IOException.class, e.getCause().getClass());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getFolders_withSpecialUseCapability_shouldReturnSpecialFolderInfo() throws Exception {
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
when(imapConnection.hasCapability(Capabilities.LIST_EXTENDED)).thenReturn(true);
|
||||
when(imapConnection.hasCapability(Capabilities.SPECIAL_USE)).thenReturn(true);
|
||||
List<ImapResponse> imapResponses = Arrays.asList(
|
||||
createImapResponse("* LIST (\\HasNoChildren) \"/\" \"INBOX\""),
|
||||
createImapResponse("* LIST (\\Noselect \\HasChildren) \"/\" \"[Gmail]\""),
|
||||
createImapResponse("* LIST (\\HasNoChildren \\All) \"/\" \"[Gmail]/All Mail\""),
|
||||
createImapResponse("* LIST (\\HasNoChildren \\Drafts) \"/\" \"[Gmail]/Drafts\""),
|
||||
createImapResponse("* LIST (\\HasNoChildren \\Important) \"/\" \"[Gmail]/Important\""),
|
||||
createImapResponse("* LIST (\\HasNoChildren \\Sent) \"/\" \"[Gmail]/Sent Mail\""),
|
||||
createImapResponse("* LIST (\\HasNoChildren \\Junk) \"/\" \"[Gmail]/Spam\""),
|
||||
createImapResponse("* LIST (\\HasNoChildren \\Flagged) \"/\" \"[Gmail]/Starred\""),
|
||||
createImapResponse("* LIST (\\HasNoChildren \\Trash) \"/\" \"[Gmail]/Trash\""),
|
||||
createImapResponse("5 OK Success")
|
||||
);
|
||||
when(imapConnection.executeSimpleCommand("LIST \"\" \"*\" RETURN (SPECIAL-USE)")).thenReturn(imapResponses);
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
|
||||
List<FolderListItem> folders = imapStore.getFolders();
|
||||
|
||||
Map<String, FolderListItem> folderMap = toFolderMap(folders);
|
||||
assertEquals(FolderType.INBOX, folderMap.get("INBOX").getType());
|
||||
assertEquals(FolderType.DRAFTS, folderMap.get("[Gmail]/Drafts").getType());
|
||||
assertEquals(FolderType.SENT, folderMap.get("[Gmail]/Sent Mail").getType());
|
||||
assertEquals(FolderType.SPAM, folderMap.get("[Gmail]/Spam").getType());
|
||||
assertEquals(FolderType.TRASH, folderMap.get("[Gmail]/Trash").getType());
|
||||
assertEquals(FolderType.ARCHIVE, folderMap.get("[Gmail]/All Mail").getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getFolders_withoutSpecialUseCapability_shouldUseSimpleListCommand() throws Exception {
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
when(imapConnection.hasCapability(Capabilities.LIST_EXTENDED)).thenReturn(true);
|
||||
when(imapConnection.hasCapability(Capabilities.SPECIAL_USE)).thenReturn(false);
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
|
||||
imapStore.getFolders();
|
||||
|
||||
verify(imapConnection, never()).executeSimpleCommand("LIST \"\" \"*\" RETURN (SPECIAL-USE)");
|
||||
verify(imapConnection).executeSimpleCommand("LIST \"\" \"*\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getFolders_withoutListExtendedCapability_shouldUseSimpleListCommand() throws Exception {
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
when(imapConnection.hasCapability(Capabilities.LIST_EXTENDED)).thenReturn(false);
|
||||
when(imapConnection.hasCapability(Capabilities.SPECIAL_USE)).thenReturn(true);
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
|
||||
imapStore.getFolders();
|
||||
|
||||
verify(imapConnection, never()).executeSimpleCommand("LIST \"\" \"*\" RETURN (SPECIAL-USE)");
|
||||
verify(imapConnection).executeSimpleCommand("LIST \"\" \"*\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getFolders_withoutSubscribedFoldersOnly() throws Exception {
|
||||
when(config.isSubscribedFoldersOnly()).thenReturn(false);
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
List<ImapResponse> imapResponses = Arrays.asList(
|
||||
createImapResponse("* LIST (\\HasNoChildren) \".\" \"INBOX\""),
|
||||
createImapResponse("* LIST (\\Noselect \\HasChildren) \".\" \"Folder\""),
|
||||
createImapResponse("* LIST (\\HasNoChildren) \".\" \"Folder.SubFolder\""),
|
||||
createImapResponse("6 OK Success")
|
||||
);
|
||||
when(imapConnection.executeSimpleCommand("LIST \"\" \"*\"")).thenReturn(imapResponses);
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
|
||||
List<FolderListItem> result = imapStore.getFolders();
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(Sets.newSet("INBOX", "Folder.SubFolder"), extractFolderServerIds(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getFolders_withSubscribedFoldersOnly_shouldOnlyReturnExistingSubscribedFolders()
|
||||
throws Exception {
|
||||
when(config.isSubscribedFoldersOnly()).thenReturn(true);
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
List<ImapResponse> lsubResponses = Arrays.asList(
|
||||
createImapResponse("* LSUB (\\HasNoChildren) \".\" \"INBOX\""),
|
||||
createImapResponse("* LSUB (\\Noselect \\HasChildren) \".\" \"Folder\""),
|
||||
createImapResponse("* LSUB (\\HasNoChildren) \".\" \"Folder.SubFolder\""),
|
||||
createImapResponse("* LSUB (\\HasNoChildren) \".\" \"SubscribedFolderThatHasBeenDeleted\""),
|
||||
createImapResponse("5 OK Success")
|
||||
);
|
||||
when(imapConnection.executeSimpleCommand("LSUB \"\" \"*\"")).thenReturn(lsubResponses);
|
||||
List<ImapResponse> imapResponses = Arrays.asList(
|
||||
createImapResponse("* LIST (\\HasNoChildren) \".\" \"INBOX\""),
|
||||
createImapResponse("* LIST (\\Noselect \\HasChildren) \".\" \"Folder\""),
|
||||
createImapResponse("* LIST (\\HasNoChildren) \".\" \"Folder.SubFolder\""),
|
||||
createImapResponse("6 OK Success")
|
||||
);
|
||||
when(imapConnection.executeSimpleCommand("LIST \"\" \"*\"")).thenReturn(imapResponses);
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
|
||||
List<FolderListItem> result = imapStore.getFolders();
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(Sets.newSet("INBOX", "Folder.SubFolder"), extractFolderServerIds(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getFolders_withNamespacePrefix() throws Exception {
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
List<ImapResponse> imapResponses = Arrays.asList(
|
||||
createImapResponse("* LIST () \".\" \"INBOX\""),
|
||||
createImapResponse("* LIST () \".\" \"INBOX.FolderOne\""),
|
||||
createImapResponse("* LIST () \".\" \"INBOX.FolderTwo\""),
|
||||
createImapResponse("5 OK Success")
|
||||
);
|
||||
when(imapConnection.executeSimpleCommand("LIST \"\" \"INBOX.*\"")).thenReturn(imapResponses);
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
imapStore.setTestCombinedPrefix("INBOX.");
|
||||
|
||||
List<FolderListItem> result = imapStore.getFolders();
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(Sets.newSet("INBOX", "INBOX.FolderOne", "INBOX.FolderTwo"), extractFolderServerIds(result));
|
||||
assertEquals(Sets.newSet("INBOX", "FolderOne", "FolderTwo"), extractFolderNames(result));
|
||||
assertEquals(Sets.newSet("INBOX", "FolderOne", "FolderTwo"), extractOldFolderServerIds(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getFolders_withFolderNotMatchingNamespacePrefix() throws Exception {
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
List<ImapResponse> imapResponses = Arrays.asList(
|
||||
createImapResponse("* LIST () \".\" \"INBOX\""),
|
||||
createImapResponse("* LIST () \".\" \"INBOX.FolderOne\""),
|
||||
createImapResponse("* LIST () \".\" \"FolderTwo\""),
|
||||
createImapResponse("5 OK Success")
|
||||
);
|
||||
when(imapConnection.executeSimpleCommand("LIST \"\" \"INBOX.*\"")).thenReturn(imapResponses);
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
imapStore.setTestCombinedPrefix("INBOX.");
|
||||
|
||||
List<FolderListItem> result = imapStore.getFolders();
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(Sets.newSet("INBOX", "INBOX.FolderOne", "FolderTwo"), extractFolderServerIds(result));
|
||||
assertEquals(Sets.newSet("INBOX", "FolderOne", "FolderTwo"), extractFolderNames(result));
|
||||
assertEquals(Sets.newSet("INBOX", "FolderOne"), extractOldFolderServerIds(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getFolders_withDuplicateFolderNames_shouldRemoveDuplicatesAndKeepFolderType()
|
||||
throws Exception {
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
when(imapConnection.hasCapability(Capabilities.LIST_EXTENDED)).thenReturn(true);
|
||||
when(imapConnection.hasCapability(Capabilities.SPECIAL_USE)).thenReturn(true);
|
||||
List<ImapResponse> imapResponses = Arrays.asList(
|
||||
createImapResponse("* LIST () \".\" \"INBOX\""),
|
||||
createImapResponse("* LIST (\\HasNoChildren) \".\" \"Junk\""),
|
||||
createImapResponse("* LIST (\\Junk) \".\" \"Junk\""),
|
||||
createImapResponse("* LIST (\\HasNoChildren) \".\" \"Junk\""),
|
||||
createImapResponse("5 OK Success")
|
||||
);
|
||||
when(imapConnection.executeSimpleCommand("LIST \"\" \"*\" RETURN (SPECIAL-USE)")).thenReturn(imapResponses);
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
|
||||
List<FolderListItem> result = imapStore.getFolders();
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(2, result.size());
|
||||
FolderListItem junkFolder = getFolderByServerId(result, "Junk");
|
||||
assertNotNull(junkFolder);
|
||||
assertEquals(FolderType.SPAM, junkFolder.getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getFolders_withoutException_shouldLeaveImapConnectionOpen() throws Exception {
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
List<ImapResponse> imapResponses = Collections.singletonList(createImapResponse("5 OK Success"));
|
||||
when(imapConnection.executeSimpleCommand(anyString())).thenReturn(imapResponses);
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
|
||||
imapStore.getFolders();
|
||||
|
||||
verify(imapConnection, never()).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getFolders_withIoException_shouldCloseImapConnection() throws Exception {
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
doThrow(IOException.class).when(imapConnection).executeSimpleCommand("LIST \"\" \"*\"");
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
|
||||
try {
|
||||
imapStore.getFolders();
|
||||
fail("Expected exception");
|
||||
} catch (MessagingException ignored) {
|
||||
}
|
||||
|
||||
verify(imapConnection).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getConnection_shouldCreateImapConnection() throws Exception {
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
|
||||
ImapConnection result = imapStore.getConnection();
|
||||
|
||||
assertSame(imapConnection, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getConnection_calledTwiceWithoutRelease_shouldCreateTwoImapConnection() throws Exception {
|
||||
ImapConnection imapConnectionOne = createMockConnection();
|
||||
ImapConnection imapConnectionTwo = createMockConnection();
|
||||
imapStore.enqueueImapConnection(imapConnectionOne);
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo);
|
||||
|
||||
ImapConnection resultOne = imapStore.getConnection();
|
||||
ImapConnection resultTwo = imapStore.getConnection();
|
||||
|
||||
assertSame(imapConnectionOne, resultOne);
|
||||
assertSame(imapConnectionTwo, resultTwo);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getConnection_calledAfterRelease_shouldReturnCachedImapConnection() throws Exception {
|
||||
ImapConnection imapConnection = createMockConnection();
|
||||
when(imapConnection.isConnected()).thenReturn(true);
|
||||
imapStore.enqueueImapConnection(imapConnection);
|
||||
ImapConnection connection = imapStore.getConnection();
|
||||
imapStore.releaseConnection(connection);
|
||||
|
||||
ImapConnection result = imapStore.getConnection();
|
||||
|
||||
assertSame(imapConnection, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getConnection_calledAfterReleaseWithAClosedConnection_shouldReturnNewImapConnectionInstance()
|
||||
throws Exception {
|
||||
ImapConnection imapConnectionOne = createMockConnection();
|
||||
ImapConnection imapConnectionTwo = createMockConnection();
|
||||
imapStore.enqueueImapConnection(imapConnectionOne);
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo);
|
||||
imapStore.getConnection();
|
||||
when(imapConnectionOne.isConnected()).thenReturn(false);
|
||||
imapStore.releaseConnection(imapConnectionOne);
|
||||
|
||||
ImapConnection result = imapStore.getConnection();
|
||||
|
||||
assertSame(imapConnectionTwo, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getConnection_withDeadConnectionInPool_shouldReturnNewImapConnectionInstance() throws Exception {
|
||||
ImapConnection imapConnectionOne = createMockConnection();
|
||||
ImapConnection imapConnectionTwo = createMockConnection();
|
||||
imapStore.enqueueImapConnection(imapConnectionOne);
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo);
|
||||
imapStore.getConnection();
|
||||
when(imapConnectionOne.isConnected()).thenReturn(true);
|
||||
doThrow(IOException.class).when(imapConnectionOne).executeSimpleCommand(Commands.NOOP);
|
||||
imapStore.releaseConnection(imapConnectionOne);
|
||||
|
||||
ImapConnection result = imapStore.getConnection();
|
||||
|
||||
assertSame(imapConnectionTwo, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getConnection_withConnectionInPoolAndCloseAllConnections_shouldReturnNewImapConnectionInstance()
|
||||
throws Exception {
|
||||
ImapConnection imapConnectionOne = createMockConnection(1);
|
||||
ImapConnection imapConnectionTwo = createMockConnection(2);
|
||||
imapStore.enqueueImapConnection(imapConnectionOne);
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo);
|
||||
imapStore.getConnection();
|
||||
when(imapConnectionOne.isConnected()).thenReturn(true);
|
||||
imapStore.releaseConnection(imapConnectionOne);
|
||||
imapStore.closeAllConnections();
|
||||
|
||||
ImapConnection result = imapStore.getConnection();
|
||||
|
||||
assertSame(imapConnectionTwo, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getConnection_withConnectionOutsideOfPoolAndCloseAllConnections_shouldReturnNewImapConnectionInstance()
|
||||
throws Exception {
|
||||
ImapConnection imapConnectionOne = createMockConnection(1);
|
||||
ImapConnection imapConnectionTwo = createMockConnection(2);
|
||||
imapStore.enqueueImapConnection(imapConnectionOne);
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo);
|
||||
imapStore.getConnection();
|
||||
when(imapConnectionOne.isConnected()).thenReturn(true);
|
||||
imapStore.closeAllConnections();
|
||||
imapStore.releaseConnection(imapConnectionOne);
|
||||
|
||||
ImapConnection result = imapStore.getConnection();
|
||||
|
||||
assertSame(imapConnectionTwo, result);
|
||||
}
|
||||
|
||||
|
||||
private ImapConnection createMockConnection() {
|
||||
ImapConnection imapConnection = mock(ImapConnection.class);
|
||||
when(imapConnection.getConnectionGeneration()).thenReturn(1);
|
||||
return imapConnection;
|
||||
}
|
||||
|
||||
private ImapConnection createMockConnection(int connectionGeneration) {
|
||||
ImapConnection imapConnection = mock(ImapConnection.class);
|
||||
when(imapConnection.getConnectionGeneration()).thenReturn(connectionGeneration);
|
||||
return imapConnection;
|
||||
}
|
||||
|
||||
|
||||
private ServerSettings createServerSettings() {
|
||||
Map<String, String> extra = ImapStoreSettings.createExtra(true, null);
|
||||
return new ServerSettings(
|
||||
"imap",
|
||||
"imap.example.org",
|
||||
143,
|
||||
ConnectionSecurity.NONE,
|
||||
AuthType.PLAIN,
|
||||
"user",
|
||||
"password",
|
||||
null,
|
||||
extra);
|
||||
}
|
||||
|
||||
private Set<String> extractFolderServerIds(List<FolderListItem> folders) {
|
||||
Set<String> folderServerIds = new HashSet<>(folders.size());
|
||||
for (FolderListItem folder : folders) {
|
||||
folderServerIds.add(folder.getServerId());
|
||||
}
|
||||
|
||||
return folderServerIds;
|
||||
}
|
||||
|
||||
private Set<String> extractFolderNames(List<FolderListItem> folders) {
|
||||
Set<String> folderNames = new HashSet<>(folders.size());
|
||||
for (FolderListItem folder : folders) {
|
||||
folderNames.add(folder.getName());
|
||||
}
|
||||
|
||||
return folderNames;
|
||||
}
|
||||
|
||||
private Set<String> extractOldFolderServerIds(List<FolderListItem> folders) {
|
||||
Set<String> folderNames = new HashSet<>(folders.size());
|
||||
for (FolderListItem folder : folders) {
|
||||
String oldServerId = folder.getOldServerId();
|
||||
if (oldServerId != null) {
|
||||
folderNames.add(oldServerId);
|
||||
}
|
||||
}
|
||||
|
||||
return folderNames;
|
||||
}
|
||||
|
||||
private FolderListItem getFolderByServerId(List<FolderListItem> result, String serverId) {
|
||||
for (FolderListItem imapFolder : result) {
|
||||
if (imapFolder.getServerId().equals(serverId)) {
|
||||
return imapFolder;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<String, FolderListItem> toFolderMap(List<FolderListItem> folders) {
|
||||
Map<String, FolderListItem> folderMap = new HashMap<>();
|
||||
for (FolderListItem folder : folders) {
|
||||
folderMap.put(folder.getServerId(), folder);
|
||||
}
|
||||
|
||||
return folderMap;
|
||||
}
|
||||
|
||||
|
||||
static class TestImapStore extends RealImapStore {
|
||||
private Deque<ImapConnection> imapConnections = new ArrayDeque<>();
|
||||
private String testCombinedPrefix;
|
||||
|
||||
public TestImapStore(ServerSettings serverSettings, ImapStoreConfig config,
|
||||
TrustedSocketFactory trustedSocketFactory, OAuth2TokenProvider oauth2TokenProvider) {
|
||||
super(serverSettings, config, trustedSocketFactory, oauth2TokenProvider);
|
||||
}
|
||||
|
||||
@Override
|
||||
ImapConnection createImapConnection() {
|
||||
if (imapConnections.isEmpty()) {
|
||||
throw new AssertionError("Unexpectedly tried to create an ImapConnection instance");
|
||||
}
|
||||
return imapConnections.pop();
|
||||
}
|
||||
|
||||
public void enqueueImapConnection(ImapConnection imapConnection) {
|
||||
imapConnections.add(imapConnection);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public String getCombinedPrefix() {
|
||||
return testCombinedPrefix != null ? testCombinedPrefix : super.getCombinedPrefix();
|
||||
}
|
||||
|
||||
void setTestCombinedPrefix(String prefix) {
|
||||
testCombinedPrefix = prefix;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,441 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.MessagingException
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.createExtra
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import java.io.IOException
|
||||
import java.util.ArrayDeque
|
||||
import java.util.Deque
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.doThrow
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.stub
|
||||
import org.mockito.kotlin.verify
|
||||
|
||||
class RealImapStoreTest {
|
||||
private val imapStore = createTestImapStore()
|
||||
|
||||
@Test
|
||||
fun `checkSettings() should create ImapConnection and call open()`() {
|
||||
val imapConnection = createMockConnection()
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
imapStore.checkSettings()
|
||||
|
||||
verify(imapConnection).open()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checkSettings() with open throwing should throw MessagingException`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { open() } doThrow IOException::class
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
try {
|
||||
imapStore.checkSettings()
|
||||
fail("Expected exception")
|
||||
} catch (e: MessagingException) {
|
||||
assertThat(e).hasMessageThat().isEqualTo("Unable to connect")
|
||||
assertThat(e).hasCauseThat().isInstanceOf(IOException::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with SPECIAL-USE capability should return special FolderInfo`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true
|
||||
on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true
|
||||
on { executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") } doReturn listOf(
|
||||
createImapResponse("""* LIST (\HasNoChildren) "/" "INBOX""""),
|
||||
createImapResponse("""* LIST (\Noselect \HasChildren) "/" "[Gmail]""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \All) "/" "[Gmail]/All Mail""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \Drafts) "/" "[Gmail]/Drafts""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \Important) "/" "[Gmail]/Important""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \Sent) "/" "[Gmail]/Sent Mail""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \Junk) "/" "[Gmail]/Spam""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \Flagged) "/" "[Gmail]/Starred""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \Trash) "/" "[Gmail]/Trash""""),
|
||||
createImapResponse("5 OK Success")
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
val foldersMap = folders.map { it.serverId to it.type }
|
||||
assertThat(foldersMap).containsExactly(
|
||||
"INBOX" to FolderType.INBOX,
|
||||
"[Gmail]/All Mail" to FolderType.ARCHIVE,
|
||||
"[Gmail]/Drafts" to FolderType.DRAFTS,
|
||||
"[Gmail]/Important" to FolderType.REGULAR,
|
||||
"[Gmail]/Sent Mail" to FolderType.SENT,
|
||||
"[Gmail]/Spam" to FolderType.SPAM,
|
||||
"[Gmail]/Starred" to FolderType.REGULAR,
|
||||
"[Gmail]/Trash" to FolderType.TRASH
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() without SPECIAL-USE capability should use simple LIST command`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true
|
||||
on { hasCapability(Capabilities.SPECIAL_USE) } doReturn false
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
imapStore.getFolders()
|
||||
|
||||
verify(imapConnection, never()).executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""")
|
||||
verify(imapConnection).executeSimpleCommand("""LIST "" "*"""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() without LIST-EXTENDED capability should use simple LIST command`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn false
|
||||
on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
imapStore.getFolders()
|
||||
|
||||
verify(imapConnection, never()).executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""")
|
||||
verify(imapConnection).executeSimpleCommand("""LIST "" "*"""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with subscribedFoldersOnly = false`() {
|
||||
val imapStore = createTestImapStore(isSubscribedFoldersOnly = false)
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand("""LIST "" "*"""") } doReturn listOf(
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "INBOX""""),
|
||||
createImapResponse("""* LIST (\Noselect \HasChildren) "." "Folder""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "Folder.SubFolder""""),
|
||||
createImapResponse("6 OK Success")
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
assertThat(folders).isNotNull()
|
||||
assertThat(folders.map { it.serverId }).containsExactly("INBOX", "Folder.SubFolder")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with subscribedFoldersOnly = true should only return existing subscribed folders`() {
|
||||
val imapStore = createTestImapStore(isSubscribedFoldersOnly = true)
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand("""LSUB "" "*"""") } doReturn listOf(
|
||||
createImapResponse("""* LSUB (\HasNoChildren) "." "INBOX""""),
|
||||
createImapResponse("""* LSUB (\Noselect \HasChildren) "." "Folder""""),
|
||||
createImapResponse("""* LSUB (\HasNoChildren) "." "Folder.SubFolder""""),
|
||||
createImapResponse("""* LSUB (\HasNoChildren) "." "SubscribedFolderThatHasBeenDeleted""""),
|
||||
createImapResponse("5 OK Success")
|
||||
)
|
||||
on { executeSimpleCommand("""LIST "" "*"""") } doReturn listOf(
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "INBOX""""),
|
||||
createImapResponse("""* LIST (\Noselect \HasChildren) "." "Folder""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "Folder.SubFolder""""),
|
||||
createImapResponse("6 OK Success")
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
assertThat(folders).isNotNull()
|
||||
assertThat(folders.map { it.serverId }).containsExactly("INBOX", "Folder.SubFolder")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with namespace prefix`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand("""LIST "" "INBOX.*"""") } doReturn listOf(
|
||||
createImapResponse("""* LIST () "." "INBOX""""),
|
||||
createImapResponse("""* LIST () "." "INBOX.FolderOne""""),
|
||||
createImapResponse("""* LIST () "." "INBOX.FolderTwo""""),
|
||||
createImapResponse("5 OK Success")
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
imapStore.setTestCombinedPrefix("INBOX.")
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
assertThat(folders).isNotNull()
|
||||
assertThat(folders.map { it.serverId }).containsExactly("INBOX", "INBOX.FolderOne", "INBOX.FolderTwo")
|
||||
assertThat(folders.map { it.name }).containsExactly("INBOX", "FolderOne", "FolderTwo")
|
||||
assertThat(folders.map { it.oldServerId }).containsExactly("INBOX", "FolderOne", "FolderTwo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with folder not matching namespace prefix`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand("""LIST "" "INBOX.*"""") } doReturn listOf(
|
||||
createImapResponse("""* LIST () "." "INBOX""""),
|
||||
createImapResponse("""* LIST () "." "INBOX.FolderOne""""),
|
||||
createImapResponse("""* LIST () "." "FolderTwo""""),
|
||||
createImapResponse("5 OK Success")
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
imapStore.setTestCombinedPrefix("INBOX.")
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
assertThat(folders).isNotNull()
|
||||
assertThat(folders.map { it.serverId }).containsExactly("INBOX", "INBOX.FolderOne", "FolderTwo")
|
||||
assertThat(folders.map { it.name }).containsExactly("INBOX", "FolderOne", "FolderTwo")
|
||||
assertThat(folders.mapNotNull { it.oldServerId }).containsExactly("INBOX", "FolderOne")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with duplicate folder names should remove duplicates and keep FolderType`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true
|
||||
on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true
|
||||
on { executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") } doReturn listOf(
|
||||
createImapResponse("""* LIST () "." "INBOX""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "Junk""""),
|
||||
createImapResponse("""* LIST (\Junk) "." "Junk""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "Junk""""),
|
||||
createImapResponse("5 OK Success")
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
assertThat(folders.map { it.serverId to it.type }).containsExactly(
|
||||
"INBOX" to FolderType.INBOX,
|
||||
"Junk" to FolderType.SPAM
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() without exception should leave ImapConnection open`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand(anyString()) } doReturn listOf(createImapResponse("5 OK Success"))
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
imapStore.getFolders()
|
||||
|
||||
verify(imapConnection, never()).close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with IOException should close ImapConnection`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand("""LIST "" "*"""") } doThrow IOException::class
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
try {
|
||||
imapStore.getFolders()
|
||||
fail("Expected exception")
|
||||
} catch (ignored: MessagingException) {
|
||||
}
|
||||
|
||||
verify(imapConnection).close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() should create ImapConnection`() {
|
||||
val imapConnection = createMockConnection()
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val result = imapStore.getConnection()
|
||||
|
||||
assertThat(result).isSameInstanceAs(imapConnection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() called twice without release should create two ImapConnection instances`() {
|
||||
val imapConnectionOne = createMockConnection()
|
||||
val imapConnectionTwo = createMockConnection()
|
||||
imapStore.enqueueImapConnection(imapConnectionOne)
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo)
|
||||
|
||||
val resultOne = imapStore.getConnection()
|
||||
val resultTwo = imapStore.getConnection()
|
||||
|
||||
assertThat(resultOne).isSameInstanceAs(imapConnectionOne)
|
||||
assertThat(resultTwo).isSameInstanceAs(imapConnectionTwo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() called after release should return cached ImapConnection`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { isConnected } doReturn true
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val connection = imapStore.getConnection()
|
||||
imapStore.releaseConnection(connection)
|
||||
|
||||
val result = imapStore.getConnection()
|
||||
|
||||
assertThat(result).isSameInstanceAs(imapConnection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() called after release with closed connection should return new ImapConnection instance`() {
|
||||
val imapConnectionOne = createMockConnection()
|
||||
val imapConnectionTwo = createMockConnection()
|
||||
imapStore.enqueueImapConnection(imapConnectionOne)
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo)
|
||||
|
||||
imapStore.getConnection()
|
||||
imapConnectionOne.stub {
|
||||
on { isConnected } doReturn false
|
||||
}
|
||||
imapStore.releaseConnection(imapConnectionOne)
|
||||
|
||||
val result = imapStore.getConnection()
|
||||
|
||||
assertThat(result).isSameInstanceAs(imapConnectionTwo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() with dead connection in pool should return new ImapConnection instance`() {
|
||||
val imapConnectionOne = createMockConnection()
|
||||
val imapConnectionTwo = createMockConnection()
|
||||
imapStore.enqueueImapConnection(imapConnectionOne)
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo)
|
||||
|
||||
imapStore.getConnection()
|
||||
imapConnectionOne.stub {
|
||||
on { isConnected } doReturn true
|
||||
on { executeSimpleCommand(Commands.NOOP) } doThrow IOException::class
|
||||
}
|
||||
imapStore.releaseConnection(imapConnectionOne)
|
||||
|
||||
val result = imapStore.getConnection()
|
||||
|
||||
assertThat(result).isSameInstanceAs(imapConnectionTwo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() with connection in pool and closeAllConnections() should return new ImapConnection instance`() {
|
||||
val imapConnectionOne = createMockConnection(1)
|
||||
val imapConnectionTwo = createMockConnection(2)
|
||||
imapStore.enqueueImapConnection(imapConnectionOne)
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo)
|
||||
|
||||
imapStore.getConnection()
|
||||
imapConnectionOne.stub {
|
||||
on { isConnected } doReturn true
|
||||
}
|
||||
imapStore.releaseConnection(imapConnectionOne)
|
||||
imapStore.closeAllConnections()
|
||||
|
||||
val result = imapStore.getConnection()
|
||||
|
||||
assertThat(result).isSameInstanceAs(imapConnectionTwo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() with connection outside of pool and closeAllConnections() should return new ImapConnection instance`() {
|
||||
val imapConnectionOne = createMockConnection(1)
|
||||
val imapConnectionTwo = createMockConnection(2)
|
||||
imapStore.enqueueImapConnection(imapConnectionOne)
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo)
|
||||
|
||||
imapStore.getConnection()
|
||||
imapConnectionOne.stub {
|
||||
on { isConnected } doReturn true
|
||||
}
|
||||
imapStore.closeAllConnections()
|
||||
imapStore.releaseConnection(imapConnectionOne)
|
||||
|
||||
val result = imapStore.getConnection()
|
||||
|
||||
assertThat(result).isSameInstanceAs(imapConnectionTwo)
|
||||
}
|
||||
|
||||
private fun createMockConnection(connectionGeneration: Int = 1): ImapConnection {
|
||||
return mock {
|
||||
on { this.connectionGeneration } doReturn connectionGeneration
|
||||
}
|
||||
}
|
||||
|
||||
private fun createServerSettings(): ServerSettings {
|
||||
return ServerSettings(
|
||||
type = "imap",
|
||||
host = "imap.example.org",
|
||||
port = 143,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = "user",
|
||||
password = "password",
|
||||
clientCertificateAlias = null,
|
||||
extra = createExtra(autoDetectNamespace = true, pathPrefix = null)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createTestImapStore(
|
||||
isSubscribedFoldersOnly: Boolean = false,
|
||||
useCompression: Boolean = false
|
||||
): TestImapStore {
|
||||
return TestImapStore(
|
||||
serverSettings = createServerSettings(),
|
||||
config = createImapStoreConfig(isSubscribedFoldersOnly, useCompression),
|
||||
trustedSocketFactory = mock(),
|
||||
oauth2TokenProvider = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun createImapStoreConfig(isSubscribedFoldersOnly: Boolean, useCompression: Boolean): ImapStoreConfig {
|
||||
return object : ImapStoreConfig {
|
||||
override val logLabel: String = "irrelevant"
|
||||
override fun isSubscribedFoldersOnly(): Boolean = isSubscribedFoldersOnly
|
||||
override fun useCompression(): Boolean = useCompression
|
||||
}
|
||||
}
|
||||
|
||||
private class TestImapStore(
|
||||
serverSettings: ServerSettings,
|
||||
config: ImapStoreConfig,
|
||||
trustedSocketFactory: TrustedSocketFactory,
|
||||
oauth2TokenProvider: OAuth2TokenProvider?
|
||||
) : RealImapStore(
|
||||
serverSettings, config, trustedSocketFactory, oauth2TokenProvider
|
||||
) {
|
||||
private val imapConnections: Deque<ImapConnection> = ArrayDeque()
|
||||
private var testCombinedPrefix: String? = null
|
||||
|
||||
override fun createImapConnection(): ImapConnection {
|
||||
if (imapConnections.isEmpty()) {
|
||||
throw AssertionError("Unexpectedly tried to create an ImapConnection instance")
|
||||
}
|
||||
|
||||
return imapConnections.pop()
|
||||
}
|
||||
|
||||
fun enqueueImapConnection(imapConnection: ImapConnection) {
|
||||
imapConnections.add(imapConnection)
|
||||
}
|
||||
|
||||
override fun getCombinedPrefix(): String {
|
||||
return testCombinedPrefix ?: super.getCombinedPrefix()
|
||||
}
|
||||
|
||||
fun setTestCombinedPrefix(prefix: String?) {
|
||||
testCombinedPrefix = prefix
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,113 +0,0 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import com.fsck.k9.mail.AuthType;
|
||||
import com.fsck.k9.mail.ConnectionSecurity;
|
||||
|
||||
|
||||
class SimpleImapSettings implements ImapSettings {
|
||||
private String host;
|
||||
private int port;
|
||||
private ConnectionSecurity connectionSecurity = ConnectionSecurity.NONE;
|
||||
private AuthType authType;
|
||||
private String username;
|
||||
private String password;
|
||||
private String pathPrefix;
|
||||
private String pathDelimiter;
|
||||
private String combinedPrefix;
|
||||
private boolean useCompression = false;
|
||||
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean useCompression() {
|
||||
return useCompression;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPathPrefix() {
|
||||
return pathPrefix;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPathPrefix(String prefix) {
|
||||
pathPrefix = prefix;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPathDelimiter() {
|
||||
return pathDelimiter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPathDelimiter(String delimiter) {
|
||||
pathDelimiter = delimiter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCombinedPrefix(String prefix) {
|
||||
combinedPrefix = prefix;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
void setUseCompression(boolean useCompression) {
|
||||
this.useCompression = useCompression;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
|
||||
internal class SimpleImapSettings(
|
||||
override val host: String,
|
||||
override val port: Int = 0,
|
||||
override val connectionSecurity: ConnectionSecurity = ConnectionSecurity.NONE,
|
||||
override val authType: AuthType,
|
||||
override val username: String,
|
||||
override val password: String? = null,
|
||||
private val useCompression: Boolean = false
|
||||
) : ImapSettings {
|
||||
override val clientCertificateAlias: String? = null
|
||||
override fun useCompression(): Boolean = useCompression
|
||||
|
||||
override var pathPrefix: String? = null
|
||||
override var pathDelimiter: String? = null
|
||||
|
||||
override fun setCombinedPrefix(prefix: String?) = Unit
|
||||
}
|
Loading…
Reference in a new issue