replicant-packages_apps_Email/provider_src/com/android/email/mail/store/ImapConnection.java

709 lines
28 KiB
Java

/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.email.mail.store;
import android.text.TextUtils;
import android.util.Base64;
import com.android.email.DebugUtils;
import com.android.email.mail.internet.AuthenticationCache;
import com.android.email.mail.store.ImapStore.ImapException;
import com.android.email.mail.store.imap.ImapConstants;
import com.android.email.mail.store.imap.ImapList;
import com.android.email.mail.store.imap.ImapResponse;
import com.android.email.mail.store.imap.ImapResponseParser;
import com.android.email.mail.store.imap.ImapUtility;
import com.android.email.mail.transport.DiscourseLogger;
import com.android.email.mail.transport.MailTransport;
import com.android.emailcommon.Logging;
import com.android.emailcommon.mail.AuthenticationFailedException;
import com.android.emailcommon.mail.CertificateValidationException;
import com.android.emailcommon.mail.MessagingException;
import com.android.mail.utils.LogUtils;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import javax.net.ssl.SSLException;
/**
* A cacheable class that stores the details for a single IMAP connection.
*/
class ImapConnection {
// Always check in FALSE
private static final boolean DEBUG_FORCE_SEND_ID = false;
// RFC 2177 defines that IDLE connections must be refreshed at least every 29 minutes
public static final int PING_IDLE_TIMEOUT = 29 * 60 * 1000;
// Special timeout for DONE operations
public static final int DONE_TIMEOUT = 5 * 1000;
// Time to wait between the first idle message and triggering the changes
private static final int IDLE_OP_READ_TIMEOUT = 500;
/** ID capability per RFC 2971*/
public static final int CAPABILITY_ID = 1 << 0;
/** NAMESPACE capability per RFC 2342 */
public static final int CAPABILITY_NAMESPACE = 1 << 1;
/** STARTTLS capability per RFC 3501 */
public static final int CAPABILITY_STARTTLS = 1 << 2;
/** UIDPLUS capability per RFC 4315 */
public static final int CAPABILITY_UIDPLUS = 1 << 3;
/** IDLE capability per RFC 2177 */
public static final int CAPABILITY_IDLE = 1 << 4;
/** The capabilities supported; a set of CAPABILITY_* values. */
private int mCapabilities;
static final String IMAP_REDACTED_LOG = "[IMAP command redacted]";
MailTransport mTransport;
private ImapResponseParser mParser;
private ImapStore mImapStore;
private String mLoginPhrase;
private String mAccessToken;
private String mIdPhrase = null;
private boolean mIdling = false;
/** # of command/response lines to log upon crash. */
private static final int DISCOURSE_LOGGER_SIZE = 64;
private final DiscourseLogger mDiscourse = new DiscourseLogger(DISCOURSE_LOGGER_SIZE);
/**
* Next tag to use. All connections associated to the same ImapStore instance share the same
* counter to make tests simpler.
* (Some of the tests involve multiple connections but only have a single counter to track the
* tag.)
*/
private final AtomicInteger mNextCommandTag = new AtomicInteger(0);
// Keep others from instantiating directly
ImapConnection(ImapStore store) {
setStore(store);
}
void setStore(ImapStore store) {
// TODO: maybe we should throw an exception if the connection is not closed here,
// if it's not currently closed, then we won't reopen it, so if the credentials have
// changed, the connection will not be reestablished.
mImapStore = store;
mLoginPhrase = null;
}
/**
* Generates and returns the phrase to be used for authentication. This will be a LOGIN with
* username and password, or an OAUTH authentication string, with username and access token.
* Currently, these are the only two auth mechanisms supported.
*
* @throws IOException
* @throws AuthenticationFailedException
* @return the login command string to sent to the IMAP server
*/
String getLoginPhrase() throws MessagingException, IOException {
// build the LOGIN string once (instead of over-and-over again.)
if (mImapStore.getUseOAuth()) {
// We'll recreate the login phrase if it's null, or if the access token
// has changed.
final String accessToken = AuthenticationCache.getInstance().retrieveAccessToken(
mImapStore.getContext(), mImapStore.getAccount());
if (mLoginPhrase == null || !TextUtils.equals(mAccessToken, accessToken)) {
mAccessToken = accessToken;
final String oauthCode = "user=" + mImapStore.getUsername() + '\001' +
"auth=Bearer " + mAccessToken + '\001' + '\001';
mLoginPhrase = ImapConstants.AUTHENTICATE + " " + ImapConstants.XOAUTH2 + " " +
Base64.encodeToString(oauthCode.getBytes(), Base64.NO_WRAP);
}
} else {
if (mLoginPhrase == null) {
if (mImapStore.getUsername() != null && mImapStore.getPassword() != null) {
// build the LOGIN string once (instead of over-and-over again.)
// apply the quoting here around the built-up password
mLoginPhrase = ImapConstants.LOGIN + " " + mImapStore.getUsername() + " "
+ ImapUtility.imapQuoted(mImapStore.getPassword());
}
}
}
return mLoginPhrase;
}
void open() throws IOException, MessagingException {
if (mTransport != null && mTransport.isOpen()) {
return;
}
try {
// copy configuration into a clean transport, if necessary
if (mTransport == null) {
mTransport = mImapStore.cloneTransport();
}
mTransport.open();
createParser();
// BANNER
mParser.readResponse();
// CAPABILITY
ImapResponse capabilities = queryCapabilities();
boolean hasStartTlsCapability =
capabilities.contains(ImapConstants.STARTTLS);
// TLS
ImapResponse newCapabilities = doStartTls(hasStartTlsCapability);
if (newCapabilities != null) {
capabilities = newCapabilities;
}
// NOTE: An IMAP response MUST be processed before issuing any new IMAP
// requests. Subsequent requests may destroy previous response data. As
// such, we save away capability information here for future use.
setCapabilities(capabilities);
String capabilityString = capabilities.flatten();
// ID
doSendId(isCapable(CAPABILITY_ID), capabilityString);
// LOGIN
doLogin();
// NAMESPACE (only valid in the Authenticated state)
doGetNamespace(isCapable(CAPABILITY_NAMESPACE));
// Gets the path separator from the server
doGetPathSeparator();
mImapStore.ensurePrefixIsValid();
} catch (SSLException e) {
if (DebugUtils.DEBUG) {
LogUtils.d(Logging.LOG_TAG, e, "SSLException");
}
throw new CertificateValidationException(e.getMessage(), e);
} catch (IOException ioe) {
// NOTE: Unlike similar code in POP3, I'm going to rethrow as-is. There is a lot
// of other code here that catches IOException and I don't want to break it.
// This catch is only here to enhance logging of connection-time issues.
if (DebugUtils.DEBUG) {
LogUtils.d(Logging.LOG_TAG, ioe, "IOException");
}
throw ioe;
} finally {
destroyResponses();
}
}
/**
* Closes the connection and releases all resources. This connection can not be used again
* until {@link #setStore(ImapStore)} is called.
*/
void close() {
if (mTransport != null) {
mTransport.close();
mTransport = null;
}
destroyResponses();
mParser = null;
mImapStore = null;
}
int getReadTimeout() throws IOException {
if (mTransport == null) {
return MailTransport.SOCKET_READ_TIMEOUT;
}
return mTransport.getReadTimeout();
}
void setReadTimeout(int timeout) throws IOException {
if (mTransport != null) {
mTransport.setReadTimeout(timeout);
}
}
/**
* Returns whether or not the specified capability is supported by the server.
*/
public boolean isCapable(int capability) {
return (mCapabilities & capability) != 0;
}
/**
* Sets the capability flags according to the response provided by the server.
* Note: We only set the capability flags that we are interested in. There are many IMAP
* capabilities that we do not track.
*/
private void setCapabilities(ImapResponse capabilities) {
if (capabilities.contains(ImapConstants.ID)) {
mCapabilities |= CAPABILITY_ID;
}
if (capabilities.contains(ImapConstants.NAMESPACE)) {
mCapabilities |= CAPABILITY_NAMESPACE;
}
if (capabilities.contains(ImapConstants.UIDPLUS)) {
mCapabilities |= CAPABILITY_UIDPLUS;
}
if (capabilities.contains(ImapConstants.STARTTLS)) {
mCapabilities |= CAPABILITY_STARTTLS;
}
if (capabilities.contains(ImapConstants.IDLE)) {
mCapabilities |= CAPABILITY_IDLE;
}
}
/**
* Create an {@link ImapResponseParser} from {@code mTransport.getInputStream()} and
* set it to {@link #mParser}.
*
* If we already have an {@link ImapResponseParser}, we
* {@link #destroyResponses()} and throw it away.
*/
private void createParser() {
destroyResponses();
mParser = new ImapResponseParser(mTransport.getInputStream(), mDiscourse);
}
void destroyResponses() {
if (mParser != null) {
mParser.destroyResponses();
}
}
boolean isTransportOpenForTest() {
return mTransport != null && mTransport.isOpen();
}
ImapResponse readResponse() throws IOException, MessagingException {
return mParser.readResponse();
}
/**
* Send a single command to the server. The command will be preceded by an IMAP command
* tag and followed by \r\n (caller need not supply them).
*
* @param command The command to send to the server
* @param sensitive If true, the command will not be logged
* @return Returns the command tag that was sent
*/
String sendCommand(String command, boolean sensitive)
throws MessagingException, IOException {
// Don't allow any command other than DONE when idling
if (mIdling && !command.equals(ImapConstants.DONE)) {
return null;
}
mIdling = command.equals(ImapConstants.IDLE);
LogUtils.d(Logging.LOG_TAG, "sendCommand %s", (sensitive ? IMAP_REDACTED_LOG : command));
open();
return sendCommandInternal(command, sensitive);
}
String sendCommandInternal(String command, boolean sensitive)
throws MessagingException, IOException {
if (mTransport == null) {
throw new IOException("Null transport");
}
String tag = Integer.toString(mNextCommandTag.incrementAndGet());
final String commandToSend;
if (command.equals(ImapConstants.DONE)) {
// Do not send a tag for DONE command
commandToSend = command;
} else {
commandToSend = tag + " " + command;
}
mTransport.writeLine(commandToSend, sensitive ? IMAP_REDACTED_LOG : null);
mDiscourse.addSentCommand(sensitive ? IMAP_REDACTED_LOG : commandToSend);
return tag;
}
/**
* Send a single, complex command to the server. The command will be preceded by an IMAP
* command tag and followed by \r\n (caller need not supply them). After each piece of the
* command, a response will be read which MUST be a continuation request.
*
* @param commands An array of Strings comprising the command to be sent to the server
* @return Returns the command tag that was sent
*/
String sendComplexCommand(List<String> commands, boolean sensitive) throws MessagingException,
IOException {
open();
String tag = Integer.toString(mNextCommandTag.incrementAndGet());
int len = commands.size();
for (int i = 0; i < len; i++) {
String commandToSend = commands.get(i);
// The first part of the command gets the tag
if (i == 0) {
commandToSend = tag + " " + commandToSend;
} else {
// Otherwise, read the response from the previous part of the command
ImapResponse response = readResponse();
// If it isn't a continuation request, that's an error
if (!response.isContinuationRequest()) {
throw new MessagingException("Expected continuation request");
}
}
// Send the command
mTransport.writeLine(commandToSend, null);
mDiscourse.addSentCommand(sensitive ? IMAP_REDACTED_LOG : commandToSend);
}
return tag;
}
List<ImapResponse> executeSimpleCommand(String command) throws IOException, MessagingException {
return executeSimpleCommand(command, false);
}
List<ImapResponse> executeIdleCommand() throws IOException, MessagingException {
mParser.expectIdlingResponse();
return executeSimpleCommand(ImapConstants.IDLE, false);
}
/**
* Read and return all of the responses from the most recent command sent to the server
*
* @return a list of ImapResponses
* @throws IOException
* @throws MessagingException
*/
List<ImapResponse> getCommandResponses() throws IOException, MessagingException {
final List<ImapResponse> responses = new ArrayList<ImapResponse>();
ImapResponse response = null;
boolean idling = false;
boolean throwSocketTimeoutEx = true;
int lastSocketTimeout = getReadTimeout();
try {
do {
response = mParser.readResponse();
if (idling) {
setReadTimeout(IDLE_OP_READ_TIMEOUT);
throwSocketTimeoutEx = false;
}
responses.add(response);
if (response.isIdling()) {
idling = true;
}
} while (idling || !response.isTagged());
} catch (SocketTimeoutException ex) {
if (throwSocketTimeoutEx) {
throw ex;
}
} finally {
mParser.resetIdlingStatus();
if (lastSocketTimeout != getReadTimeout()) {
setReadTimeout(lastSocketTimeout);
}
}
// When idling, any response is valid; otherwise it must be OK
if (!response.isOk() && !idling) {
final String toString = response.toString();
final String status = response.getStatusOrEmpty().getString();
final String alert = response.getAlertTextOrEmpty().getString();
final String responseCode = response.getResponseCodeOrEmpty().getString();
destroyResponses();
// if the response code indicates an error occurred within the server, indicate that
if (ImapConstants.UNAVAILABLE.equals(responseCode)) {
throw new MessagingException(MessagingException.SERVER_ERROR, alert);
}
throw new ImapException(toString, status, alert, responseCode);
}
return responses;
}
/**
* Execute a simple command at the server, a simple command being one that is sent in a single
* line of text
*
* @param command the command to send to the server
* @param sensitive whether the command should be redacted in logs (used for login)
* @return a list of ImapResponses
* @throws IOException
* @throws MessagingException
*/
List<ImapResponse> executeSimpleCommand(String command, boolean sensitive)
throws IOException, MessagingException {
// TODO: It may be nice to catch IOExceptions and close the connection here.
// Currently, we expect callers to do that, but if they fail to we'll be in a broken state.
sendCommand(command, sensitive);
return getCommandResponses();
}
/**
* Execute a complex command at the server, a complex command being one that must be sent in
* multiple lines due to the use of string literals
*
* @param commands a list of strings that comprise the command to be sent to the server
* @param sensitive whether the command should be redacted in logs (used for login)
* @return a list of ImapResponses
* @throws IOException
* @throws MessagingException
*/
List<ImapResponse> executeComplexCommand(List<String> commands, boolean sensitive)
throws IOException, MessagingException {
sendComplexCommand(commands, sensitive);
return getCommandResponses();
}
/**
* Query server for capabilities.
*/
private ImapResponse queryCapabilities() throws IOException, MessagingException {
ImapResponse capabilityResponse = null;
for (ImapResponse r : executeSimpleCommand(ImapConstants.CAPABILITY)) {
if (r.is(0, ImapConstants.CAPABILITY)) {
capabilityResponse = r;
break;
}
}
if (capabilityResponse == null) {
throw new MessagingException("Invalid CAPABILITY response received");
}
return capabilityResponse;
}
/**
* Sends client identification information to the IMAP server per RFC 2971. If
* the server does not support the ID command, this will perform no operation.
*
* Interoperability hack: Never send ID to *.secureserver.net, which sends back a
* malformed response that our parser can't deal with.
*/
private void doSendId(boolean hasIdCapability, String capabilities)
throws MessagingException {
if (!hasIdCapability) return;
// Never send ID to *.secureserver.net
String host = mTransport.getHost();
if (host.toLowerCase().endsWith(".secureserver.net")) return;
// Assign user-agent string (for RFC2971 ID command)
String mUserAgent =
ImapStore.getImapId(mImapStore.getContext(), mImapStore.getUsername(), host,
capabilities);
if (mUserAgent != null) {
mIdPhrase = ImapConstants.ID + " (" + mUserAgent + ")";
} else if (DEBUG_FORCE_SEND_ID) {
mIdPhrase = ImapConstants.ID + " " + ImapConstants.NIL;
}
// else: mIdPhrase = null, no ID will be emitted
// Send user-agent in an RFC2971 ID command
if (mIdPhrase != null) {
try {
executeSimpleCommand(mIdPhrase);
} catch (ImapException ie) {
// Log for debugging, but this is not a fatal problem.
if (DebugUtils.DEBUG) {
LogUtils.d(Logging.LOG_TAG, ie, "ImapException");
}
} catch (IOException ioe) {
// Special case to handle malformed OK responses and ignore them.
// A true IOException will recur on the following login steps
// This can go away after the parser is fixed - see bug 2138981
}
}
}
/**
* Gets the user's Personal Namespace from the IMAP server per RFC 2342. If the user
* explicitly sets a namespace (using setup UI) or if the server does not support the
* namespace command, this will perform no operation.
*/
private void doGetNamespace(boolean hasNamespaceCapability) throws MessagingException {
// user did not specify a hard-coded prefix; try to get it from the server
if (hasNamespaceCapability && !mImapStore.isUserPrefixSet()) {
List<ImapResponse> responseList = Collections.emptyList();
try {
responseList = executeSimpleCommand(ImapConstants.NAMESPACE);
} catch (ImapException ie) {
// Log for debugging, but this is not a fatal problem.
if (DebugUtils.DEBUG) {
LogUtils.d(Logging.LOG_TAG, ie, "ImapException");
}
} catch (IOException ioe) {
// Special case to handle malformed OK responses and ignore them.
}
for (ImapResponse response: responseList) {
if (response.isDataResponse(0, ImapConstants.NAMESPACE)) {
ImapList namespaceList = response.getListOrEmpty(1);
ImapList namespace = namespaceList.getListOrEmpty(0);
String namespaceString = namespace.getStringOrEmpty(0).getString();
if (!TextUtils.isEmpty(namespaceString)) {
mImapStore.setPathPrefix(ImapStore.decodeFolderName(namespaceString, null));
mImapStore.setPathSeparator(namespace.getStringOrEmpty(1).getString());
}
}
}
}
}
/**
* Logs into the IMAP server
*/
private void doLogin() throws IOException, MessagingException, AuthenticationFailedException {
try {
if (mImapStore.getUseOAuth()) {
// SASL authentication can take multiple steps. Currently the only SASL
// authentication supported is OAuth.
doSASLAuth();
} else {
executeSimpleCommand(getLoginPhrase(), true);
}
} catch (ImapException ie) {
if (DebugUtils.DEBUG) {
LogUtils.d(Logging.LOG_TAG, ie, "ImapException");
}
final String status = ie.getStatus();
final String code = ie.getResponseCode();
final String alertText = ie.getAlertText();
// if the response code indicates expired or bad credentials, throw a special exception
if (ImapConstants.AUTHENTICATIONFAILED.equals(code) ||
ImapConstants.EXPIRED.equals(code) ||
(ImapConstants.NO.equals(status) && TextUtils.isEmpty(code))) {
throw new AuthenticationFailedException(alertText, ie);
}
throw new MessagingException(alertText, ie);
}
}
/**
* Performs an SASL authentication. Currently, the only type of SASL authentication supported
* is OAuth.
* @throws MessagingException
* @throws IOException
*/
private void doSASLAuth() throws MessagingException, IOException {
LogUtils.d(Logging.LOG_TAG, "doSASLAuth");
ImapResponse response = getOAuthResponse();
if (!response.isOk()) {
// Failed to authenticate. This may be just due to an expired token.
LogUtils.d(Logging.LOG_TAG, "failed to authenticate, retrying");
destroyResponses();
// Clear the login phrase, this will force us to refresh the auth token.
mLoginPhrase = null;
// Close the transport so that we'll retry the authentication.
if (mTransport != null) {
mTransport.close();
mTransport = null;
}
response = getOAuthResponse();
if (!response.isOk()) {
LogUtils.d(Logging.LOG_TAG, "failed to authenticate, giving up");
destroyResponses();
throw new AuthenticationFailedException("OAuth failed after refresh");
}
}
}
private ImapResponse getOAuthResponse() throws IOException, MessagingException {
ImapResponse response;
sendCommandInternal(getLoginPhrase(), true);
do {
response = mParser.readResponse();
} while (!response.isTagged() && !response.isContinuationRequest());
if (response.isContinuationRequest()) {
// SASL allows for a challenge/response type authentication, so if it doesn't yet have
// enough info, it will send back a continuation request.
// Currently, the only type of authentication we support is OAuth. The only case where
// it will send a continuation request is when we fail to authenticate. We need to
// reply with a CR/LF, and it will then return with a NO response.
sendCommandInternal("", true);
response = readResponse();
}
// if the response code indicates an error occurred within the server, indicate that
final String responseCode = response.getResponseCodeOrEmpty().getString();
if (ImapConstants.UNAVAILABLE.equals(responseCode)) {
final String alert = response.getAlertTextOrEmpty().getString();
throw new MessagingException(MessagingException.SERVER_ERROR, alert);
}
return response;
}
/**
* Gets the path separator per the LIST command in RFC 3501. If the path separator
* was obtained while obtaining the namespace or there is no prefix defined, this
* will perform no operation.
*/
private void doGetPathSeparator() throws MessagingException {
// user did not specify a hard-coded prefix; try to get it from the server
if (mImapStore.isUserPrefixSet()) {
List<ImapResponse> responseList = Collections.emptyList();
try {
responseList = executeSimpleCommand(ImapConstants.LIST + " \"\" \"\"");
} catch (ImapException ie) {
// Log for debugging, but this is not a fatal problem.
if (DebugUtils.DEBUG) {
LogUtils.d(Logging.LOG_TAG, ie, "ImapException");
}
} catch (IOException ioe) {
// Special case to handle malformed OK responses and ignore them.
}
for (ImapResponse response: responseList) {
if (response.isDataResponse(0, ImapConstants.LIST)) {
mImapStore.setPathSeparator(response.getStringOrEmpty(2).getString());
}
}
}
}
/**
* Starts a TLS session with the IMAP server per RFC 3501. If the user has not opted
* to use TLS or the server does not support the TLS capability, this will perform
* no operation.
*/
private ImapResponse doStartTls(boolean hasStartTlsCapability)
throws IOException, MessagingException {
if (mTransport.canTryTlsSecurity()) {
if (hasStartTlsCapability) {
// STARTTLS
executeSimpleCommand(ImapConstants.STARTTLS);
mTransport.reopenTls();
createParser();
// Per RFC requirement (3501-6.2.1) gather new capabilities
return(queryCapabilities());
} else {
if (DebugUtils.DEBUG) {
LogUtils.d(Logging.LOG_TAG, "TLS not supported but required");
}
throw new MessagingException(MessagingException.TLS_REQUIRED);
}
}
return null;
}
/** @see DiscourseLogger#logLastDiscourse() */
void logLastDiscourse() {
mDiscourse.logLastDiscourse();
}
}