ImapConnection.java revision 20bf1f632f47c1ba408d23d87b7d9dc3fc5ba5ec
1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.email.mail.store;
18
19import android.text.TextUtils;
20import android.util.Log;
21
22import com.android.email.Email;
23import com.android.email.mail.Transport;
24import com.android.email.mail.store.ImapStore.ImapException;
25import com.android.email.mail.store.imap.ImapConstants;
26import com.android.email.mail.store.imap.ImapList;
27import com.android.email.mail.store.imap.ImapResponse;
28import com.android.email.mail.store.imap.ImapResponseParser;
29import com.android.email.mail.store.imap.ImapUtility;
30import com.android.email.mail.transport.DiscourseLogger;
31import com.android.email.mail.transport.MailTransport;
32import com.android.emailcommon.Logging;
33import com.android.emailcommon.mail.AuthenticationFailedException;
34import com.android.emailcommon.mail.CertificateValidationException;
35import com.android.emailcommon.mail.MessagingException;
36
37import java.io.IOException;
38import java.util.ArrayList;
39import java.util.Collections;
40import java.util.List;
41import java.util.concurrent.atomic.AtomicInteger;
42
43import javax.net.ssl.SSLException;
44
45/**
46 * A cacheable class that stores the details for a single IMAP connection.
47 */
48class ImapConnection {
49    // Always check in FALSE
50    private static final boolean DEBUG_FORCE_SEND_ID = false;
51
52    /** ID capability per RFC 2971*/
53    public static final int CAPABILITY_ID        = 1 << 0;
54    /** NAMESPACE capability per RFC 2342 */
55    public static final int CAPABILITY_NAMESPACE = 1 << 1;
56    /** STARTTLS capability per RFC 3501 */
57    public static final int CAPABILITY_STARTTLS  = 1 << 2;
58    /** UIDPLUS capability per RFC 4315 */
59    public static final int CAPABILITY_UIDPLUS   = 1 << 3;
60
61    /** The capabilities supported; a set of CAPABILITY_* values. */
62    private int mCapabilities;
63    private static final String IMAP_REDACTED_LOG = "[IMAP command redacted]";
64    Transport mTransport;
65    private ImapResponseParser mParser;
66    private ImapStore mImapStore;
67    private String mUsername;
68    private String mLoginPhrase;
69    private String mIdPhrase = null;
70    /** # of command/response lines to log upon crash. */
71    private static final int DISCOURSE_LOGGER_SIZE = 64;
72    private final DiscourseLogger mDiscourse = new DiscourseLogger(DISCOURSE_LOGGER_SIZE);
73    /**
74     * Next tag to use.  All connections associated to the same ImapStore instance share the same
75     * counter to make tests simpler.
76     * (Some of the tests involve multiple connections but only have a single counter to track the
77     * tag.)
78     */
79    private final AtomicInteger mNextCommandTag = new AtomicInteger(0);
80
81
82    // Keep others from instantiating directly
83    ImapConnection(ImapStore store, String username, String password) {
84        setStore(store, username, password);
85    }
86
87    void setStore(ImapStore store, String username, String password) {
88        if (username != null && password != null) {
89            mUsername = username;
90
91            // build the LOGIN string once (instead of over-and-over again.)
92            // apply the quoting here around the built-up password
93            mLoginPhrase = ImapConstants.LOGIN + " " + mUsername + " "
94                    + ImapUtility.imapQuoted(password);
95        }
96        mImapStore = store;
97    }
98    void open() throws IOException, MessagingException {
99        if (mTransport != null && mTransport.isOpen()) {
100            return;
101        }
102
103        try {
104            // copy configuration into a clean transport, if necessary
105            if (mTransport == null) {
106                mTransport = mImapStore.cloneTransport();
107            }
108
109            mTransport.open();
110            mTransport.setSoTimeout(MailTransport.SOCKET_READ_TIMEOUT);
111
112            createParser();
113
114            // BANNER
115            mParser.readResponse();
116
117            // CAPABILITY
118            ImapResponse capabilities = queryCapabilities();
119
120            boolean hasStartTlsCapability =
121                capabilities.contains(ImapConstants.STARTTLS);
122
123            // TLS
124            ImapResponse newCapabilities = doStartTls(hasStartTlsCapability);
125            if (newCapabilities != null) {
126                capabilities = newCapabilities;
127            }
128
129            // NOTE: An IMAP response MUST be processed before issuing any new IMAP
130            // requests. Subsequent requests may destroy previous response data. As
131            // such, we save away capability information here for future use.
132            setCapabilities(capabilities);
133            String capabilityString = capabilities.flatten();
134
135            // ID
136            doSendId(isCapable(CAPABILITY_ID), capabilityString);
137
138            // LOGIN
139            doLogin();
140
141            // NAMESPACE (only valid in the Authenticated state)
142            doGetNamespace(isCapable(CAPABILITY_NAMESPACE));
143
144            // Gets the path separator from the server
145            doGetPathSeparator();
146
147            mImapStore.ensurePrefixIsValid();
148        } catch (SSLException e) {
149            if (Email.DEBUG) {
150                Log.d(Logging.LOG_TAG, e.toString());
151            }
152            throw new CertificateValidationException(e.getMessage(), e);
153        } catch (IOException ioe) {
154            // NOTE:  Unlike similar code in POP3, I'm going to rethrow as-is.  There is a lot
155            // of other code here that catches IOException and I don't want to break it.
156            // This catch is only here to enhance logging of connection-time issues.
157            if (Email.DEBUG) {
158                Log.d(Logging.LOG_TAG, ioe.toString());
159            }
160            throw ioe;
161        } finally {
162            destroyResponses();
163        }
164    }
165
166    /**
167     * Closes the connection and releases all resources. This connection can not be used again
168     * until {@link #setStore(ImapStore, String, String)} is called.
169     */
170    void close() {
171        if (mTransport != null) {
172            mTransport.close();
173            mTransport = null;
174        }
175        destroyResponses();
176        mParser = null;
177        mImapStore = null;
178    }
179
180    /**
181     * Returns whether or not the specified capability is supported by the server.
182     */
183    private boolean isCapable(int capability) {
184        return (mCapabilities & capability) != 0;
185    }
186
187    /**
188     * Sets the capability flags according to the response provided by the server.
189     * Note: We only set the capability flags that we are interested in. There are many IMAP
190     * capabilities that we do not track.
191     */
192    private void setCapabilities(ImapResponse capabilities) {
193        if (capabilities.contains(ImapConstants.ID)) {
194            mCapabilities |= CAPABILITY_ID;
195        }
196        if (capabilities.contains(ImapConstants.NAMESPACE)) {
197            mCapabilities |= CAPABILITY_NAMESPACE;
198        }
199        if (capabilities.contains(ImapConstants.UIDPLUS)) {
200            mCapabilities |= CAPABILITY_UIDPLUS;
201        }
202        if (capabilities.contains(ImapConstants.STARTTLS)) {
203            mCapabilities |= CAPABILITY_STARTTLS;
204        }
205    }
206
207    /**
208     * Create an {@link ImapResponseParser} from {@code mTransport.getInputStream()} and
209     * set it to {@link #mParser}.
210     *
211     * If we already have an {@link ImapResponseParser}, we
212     * {@link #destroyResponses()} and throw it away.
213     */
214    private void createParser() {
215        destroyResponses();
216        mParser = new ImapResponseParser(mTransport.getInputStream(), mDiscourse);
217    }
218
219    void destroyResponses() {
220        if (mParser != null) {
221            mParser.destroyResponses();
222        }
223    }
224
225    boolean isTransportOpenForTest() {
226        return mTransport != null ? mTransport.isOpen() : false;
227    }
228
229    ImapResponse readResponse() throws IOException, MessagingException {
230        return mParser.readResponse();
231    }
232
233    /**
234     * Send a single command to the server.  The command will be preceded by an IMAP command
235     * tag and followed by \r\n (caller need not supply them).
236     *
237     * @param command The command to send to the server
238     * @param sensitive If true, the command will not be logged
239     * @return Returns the command tag that was sent
240     */
241    String sendCommand(String command, boolean sensitive)
242        throws MessagingException, IOException {
243        open();
244        String tag = Integer.toString(mNextCommandTag.incrementAndGet());
245        String commandToSend = tag + " " + command;
246        mTransport.writeLine(commandToSend, sensitive ? IMAP_REDACTED_LOG : null);
247        mDiscourse.addSentCommand(sensitive ? IMAP_REDACTED_LOG : commandToSend);
248        return tag;
249    }
250
251
252    /**
253     * Send a single, complex command to the server.  The command will be preceded by an IMAP
254     * command tag and followed by \r\n (caller need not supply them).  After each piece of the
255     * command, a response will be read which MUST be a continuation request.
256     *
257     * @param commands An array of Strings comprising the command to be sent to the server
258     * @return Returns the command tag that was sent
259     */
260    String sendComplexCommand(List<String> commands, boolean sensitive) throws MessagingException,
261            IOException {
262        open();
263        String tag = Integer.toString(mNextCommandTag.incrementAndGet());
264        int len = commands.size();
265        for (int i = 0; i < len; i++) {
266            String commandToSend = commands.get(i);
267            // The first part of the command gets the tag
268            if (i == 0) {
269                commandToSend = tag + " " + commandToSend;
270            } else {
271                // Otherwise, read the response from the previous part of the command
272                ImapResponse response = readResponse();
273                // If it isn't a continuation request, that's an error
274                if (!response.isContinuationRequest()) {
275                    throw new MessagingException("Expected continuation request");
276                }
277            }
278            // Send the command
279            mTransport.writeLine(commandToSend, null);
280            mDiscourse.addSentCommand(sensitive ? IMAP_REDACTED_LOG : commandToSend);
281        }
282        return tag;
283    }
284
285    List<ImapResponse> executeSimpleCommand(String command) throws IOException,
286            MessagingException {
287        return executeSimpleCommand(command, false);
288    }
289
290    /**
291     * Read and return all of the responses from the most recent command sent to the server
292     *
293     * @return a list of ImapResponses
294     * @throws IOException
295     * @throws MessagingException
296     */
297    List<ImapResponse> getCommandResponses() throws IOException, MessagingException {
298        ArrayList<ImapResponse> responses = new ArrayList<ImapResponse>();
299        ImapResponse response;
300        do {
301            response = mParser.readResponse();
302            responses.add(response);
303        } while (!response.isTagged());
304        if (!response.isOk()) {
305            final String toString = response.toString();
306            final String alert = response.getAlertTextOrEmpty().getString();
307            destroyResponses();
308            throw new ImapException(toString, alert);
309        }
310        return responses;
311    }
312
313    /**
314     * Execute a simple command at the server, a simple command being one that is sent in a single
315     * line of text
316     *
317     * @param command the command to send to the server
318     * @param sensitive whether the command should be redacted in logs (used for login)
319     * @return a list of ImapResponses
320     * @throws IOException
321     * @throws MessagingException
322     */
323     List<ImapResponse> executeSimpleCommand(String command, boolean sensitive)
324            throws IOException, MessagingException {
325        sendCommand(command, sensitive);
326        return getCommandResponses();
327    }
328
329     /**
330      * Execute a complex command at the server, a complex command being one that must be sent in
331      * multiple lines due to the use of string literals
332      *
333      * @param commands a list of strings that comprise the command to be sent to the server
334      * @param sensitive whether the command should be redacted in logs (used for login)
335      * @return a list of ImapResponses
336      * @throws IOException
337      * @throws MessagingException
338      */
339      List<ImapResponse> executeComplexCommand(List<String> commands, boolean sensitive)
340            throws IOException, MessagingException {
341        sendComplexCommand(commands, sensitive);
342        return getCommandResponses();
343    }
344
345    /**
346     * Query server for capabilities.
347     */
348    private ImapResponse queryCapabilities() throws IOException, MessagingException {
349        ImapResponse capabilityResponse = null;
350        for (ImapResponse r : executeSimpleCommand(ImapConstants.CAPABILITY)) {
351            if (r.is(0, ImapConstants.CAPABILITY)) {
352                capabilityResponse = r;
353                break;
354            }
355        }
356        if (capabilityResponse == null) {
357            throw new MessagingException("Invalid CAPABILITY response received");
358        }
359        return capabilityResponse;
360    }
361
362    /**
363     * Sends client identification information to the IMAP server per RFC 2971. If
364     * the server does not support the ID command, this will perform no operation.
365     *
366     * Interoperability hack:  Never send ID to *.secureserver.net, which sends back a
367     * malformed response that our parser can't deal with.
368     */
369    private void doSendId(boolean hasIdCapability, String capabilities)
370            throws MessagingException {
371        if (!hasIdCapability) return;
372
373        // Never send ID to *.secureserver.net
374        String host = mTransport.getHost();
375        if (host.toLowerCase().endsWith(".secureserver.net")) return;
376
377        // Assign user-agent string (for RFC2971 ID command)
378        String mUserAgent =
379                ImapStore.getImapId(mImapStore.getContext(), mUsername, host, capabilities);
380
381        if (mUserAgent != null) {
382            mIdPhrase = ImapConstants.ID + " (" + mUserAgent + ")";
383        } else if (DEBUG_FORCE_SEND_ID) {
384            mIdPhrase = ImapConstants.ID + " " + ImapConstants.NIL;
385        }
386        // else: mIdPhrase = null, no ID will be emitted
387
388        // Send user-agent in an RFC2971 ID command
389        if (mIdPhrase != null) {
390            try {
391                executeSimpleCommand(mIdPhrase);
392            } catch (ImapException ie) {
393                // Log for debugging, but this is not a fatal problem.
394                if (Email.DEBUG) {
395                    Log.d(Logging.LOG_TAG, ie.toString());
396                }
397            } catch (IOException ioe) {
398                // Special case to handle malformed OK responses and ignore them.
399                // A true IOException will recur on the following login steps
400                // This can go away after the parser is fixed - see bug 2138981
401            }
402        }
403    }
404
405    /**
406     * Gets the user's Personal Namespace from the IMAP server per RFC 2342. If the user
407     * explicitly sets a namespace (using setup UI) or if the server does not support the
408     * namespace command, this will perform no operation.
409     */
410    private void doGetNamespace(boolean hasNamespaceCapability) throws MessagingException {
411        // user did not specify a hard-coded prefix; try to get it from the server
412        if (hasNamespaceCapability && !mImapStore.isUserPrefixSet()) {
413            List<ImapResponse> responseList = Collections.emptyList();
414
415            try {
416                responseList = executeSimpleCommand(ImapConstants.NAMESPACE);
417            } catch (ImapException ie) {
418                // Log for debugging, but this is not a fatal problem.
419                if (Email.DEBUG) {
420                    Log.d(Logging.LOG_TAG, ie.toString());
421                }
422            } catch (IOException ioe) {
423                // Special case to handle malformed OK responses and ignore them.
424            }
425
426            for (ImapResponse response: responseList) {
427                if (response.isDataResponse(0, ImapConstants.NAMESPACE)) {
428                    ImapList namespaceList = response.getListOrEmpty(1);
429                    ImapList namespace = namespaceList.getListOrEmpty(0);
430                    String namespaceString = namespace.getStringOrEmpty(0).getString();
431                    if (!TextUtils.isEmpty(namespaceString)) {
432                        mImapStore.setPathPrefix(ImapStore.decodeFolderName(namespaceString, null));
433                        mImapStore.setPathSeparator(namespace.getStringOrEmpty(1).getString());
434                    }
435                }
436            }
437        }
438    }
439
440    /**
441     * Logs into the IMAP server
442     */
443    private void doLogin()
444            throws IOException, MessagingException, AuthenticationFailedException {
445        try {
446            // TODO eventually we need to add additional authentication
447            // options such as SASL
448            executeSimpleCommand(mLoginPhrase, true);
449        } catch (ImapException ie) {
450            if (Email.DEBUG) {
451                Log.d(Logging.LOG_TAG, ie.toString());
452            }
453            throw new AuthenticationFailedException(ie.getAlertText(), ie);
454
455        } catch (MessagingException me) {
456            throw new AuthenticationFailedException(null, me);
457        }
458    }
459
460    /**
461     * Gets the path separator per the LIST command in RFC 3501. If the path separator
462     * was obtained while obtaining the namespace or there is no prefix defined, this
463     * will perform no operation.
464     */
465    private void doGetPathSeparator() throws MessagingException {
466        // user did not specify a hard-coded prefix; try to get it from the server
467        if (mImapStore.isUserPrefixSet()) {
468            List<ImapResponse> responseList = Collections.emptyList();
469
470            try {
471                responseList = executeSimpleCommand(ImapConstants.LIST + " \"\" \"\"");
472            } catch (ImapException ie) {
473                // Log for debugging, but this is not a fatal problem.
474                if (Email.DEBUG) {
475                    Log.d(Logging.LOG_TAG, ie.toString());
476                }
477            } catch (IOException ioe) {
478                // Special case to handle malformed OK responses and ignore them.
479            }
480
481            for (ImapResponse response: responseList) {
482                if (response.isDataResponse(0, ImapConstants.LIST)) {
483                    mImapStore.setPathSeparator(response.getStringOrEmpty(2).getString());
484                }
485            }
486        }
487    }
488
489    /**
490     * Starts a TLS session with the IMAP server per RFC 3501. If the user has not opted
491     * to use TLS or the server does not support the TLS capability, this will perform
492     * no operation.
493     */
494    private ImapResponse doStartTls(boolean hasStartTlsCapability)
495            throws IOException, MessagingException {
496        if (mTransport.canTryTlsSecurity()) {
497            if (hasStartTlsCapability) {
498                // STARTTLS
499                executeSimpleCommand(ImapConstants.STARTTLS);
500
501                mTransport.reopenTls();
502                mTransport.setSoTimeout(MailTransport.SOCKET_READ_TIMEOUT);
503                createParser();
504                // Per RFC requirement (3501-6.2.1) gather new capabilities
505                return(queryCapabilities());
506            } else {
507                if (Email.DEBUG) {
508                    Log.d(Logging.LOG_TAG, "TLS not supported but required");
509                }
510                throw new MessagingException(MessagingException.TLS_REQUIRED);
511            }
512        }
513        return null;
514    }
515
516    /** @see DiscourseLogger#logLastDiscourse() */
517    void logLastDiscourse() {
518        mDiscourse.logLastDiscourse();
519    }
520}