Merge pull request #6237 from thundernest/convert_to_kotlin

Convert some IMAP-related classes to Kotlin
This commit is contained in:
cketti 2022-08-11 17:49:58 +02:00 committed by GitHub
commit 2b0a8469b8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1757 additions and 2009 deletions

View file

@ -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);
}

View file

@ -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?)
}

View file

@ -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);
}
}

View file

@ -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
}
}

View file

@ -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;
}
}
}

View file

@ -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)

View file

@ -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 {

View file

@ -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

View 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;
}
}
}

View file

@ -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
}
}
}

View file

@ -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;
}
}

View file

@ -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
}