ImapStore.java revision 22208771b7b39c5d131372ba6bc45ab23cc22232
1/*
2 * Copyright (C) 2008 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 com.android.email.Email;
20import com.android.email.LegacyConversions;
21import com.android.email.Preferences;
22import com.android.email.VendorPolicyLoader;
23import com.android.email.mail.Store;
24import com.android.email.mail.Transport;
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.ImapString;
30import com.android.email.mail.store.imap.ImapUtility;
31import com.android.email.mail.transport.DiscourseLogger;
32import com.android.email.mail.transport.MailTransport;
33import com.android.emailcommon.Logging;
34import com.android.emailcommon.internet.MimeMessage;
35import com.android.emailcommon.mail.AuthenticationFailedException;
36import com.android.emailcommon.mail.CertificateValidationException;
37import com.android.emailcommon.mail.Flag;
38import com.android.emailcommon.mail.Folder;
39import com.android.emailcommon.mail.Message;
40import com.android.emailcommon.mail.MessagingException;
41import com.android.emailcommon.provider.EmailContent.Account;
42import com.android.emailcommon.provider.EmailContent.HostAuth;
43import com.android.emailcommon.provider.EmailContent.Mailbox;
44import com.android.emailcommon.service.EmailServiceProxy;
45import com.android.emailcommon.utility.Utility;
46import com.beetstra.jutf7.CharsetProvider;
47import com.google.common.annotations.VisibleForTesting;
48
49import android.content.Context;
50import android.os.Build;
51import android.os.Bundle;
52import android.telephony.TelephonyManager;
53import android.text.TextUtils;
54import android.util.Base64;
55import android.util.Log;
56
57import java.io.IOException;
58import java.io.InputStream;
59import java.nio.ByteBuffer;
60import java.nio.charset.Charset;
61import java.security.MessageDigest;
62import java.security.NoSuchAlgorithmException;
63import java.util.ArrayList;
64import java.util.Collection;
65import java.util.Collections;
66import java.util.HashMap;
67import java.util.List;
68import java.util.Set;
69import java.util.concurrent.ConcurrentLinkedQueue;
70import java.util.concurrent.atomic.AtomicInteger;
71import java.util.regex.Pattern;
72
73import javax.net.ssl.SSLException;
74
75/**
76 * <pre>
77 * TODO Need to start keeping track of UIDVALIDITY
78 * TODO Need a default response handler for things like folder updates
79 * TODO In fetch(), if we need a ImapMessage and were given
80 * TODO Collect ALERT messages and show them to users.
81 * something else we can try to do a pre-fetch first.
82 *
83 * ftp://ftp.isi.edu/in-notes/rfc2683.txt When a client asks for
84 * certain information in a FETCH command, the server may return the requested
85 * information in any order, not necessarily in the order that it was requested.
86 * Further, the server may return the information in separate FETCH responses
87 * and may also return information that was not explicitly requested (to reflect
88 * to the client changes in the state of the subject message).
89 * </pre>
90 */
91public class ImapStore extends Store {
92
93    // Always check in FALSE
94    private static final boolean DEBUG_FORCE_SEND_ID = false;
95
96    static final int COPY_BUFFER_SIZE = 16*1024;
97
98    static final Flag[] PERMANENT_FLAGS = { Flag.DELETED, Flag.SEEN, Flag.FLAGGED };
99    private final Context mContext;
100    private final Account mAccount;
101    private Transport mRootTransport;
102    private String mUsername;
103    private String mPassword;
104    private String mLoginPhrase;
105    private String mIdPhrase = null;
106    @VisibleForTesting static String sImapId = null;
107    /*package*/ String mPathPrefix;
108    /*package*/ String mPathSeparator;
109
110    private final ConcurrentLinkedQueue<ImapConnection> mConnectionPool =
111            new ConcurrentLinkedQueue<ImapConnection>();
112
113    /**
114     * Charset used for converting folder names to and from UTF-7 as defined by RFC 3501.
115     */
116    private static final Charset MODIFIED_UTF_7_CHARSET =
117            new CharsetProvider().charsetForName("X-RFC-3501");
118
119    /**
120     * Cache of ImapFolder objects. ImapFolders are attached to a given folder on the server
121     * and as long as their associated connection remains open they are reusable between
122     * requests. This cache lets us make sure we always reuse, if possible, for a given
123     * folder name.
124     */
125    private final HashMap<String, ImapFolder> mFolderCache = new HashMap<String, ImapFolder>();
126
127    /**
128     * Next tag to use.  All connections associated to the same ImapStore instance share the same
129     * counter to make tests simpler.
130     * (Some of the tests involve multiple connections but only have a single counter to track the
131     * tag.)
132     */
133    private final AtomicInteger mNextCommandTag = new AtomicInteger(0);
134
135    /**
136     * Static named constructor.
137     */
138    public static Store newInstance(Account account, Context context,
139            PersistentDataCallbacks callbacks) throws MessagingException {
140        return new ImapStore(context, account);
141    }
142
143    /**
144     * Creates a new store for the given account.
145     */
146    private ImapStore(Context context, Account account) throws MessagingException {
147        mContext = context;
148        mAccount = account;
149
150        HostAuth recvAuth = account.getOrCreateHostAuthRecv(context);
151        if (recvAuth == null || !STORE_SCHEME_IMAP.equalsIgnoreCase(recvAuth.mProtocol)) {
152            throw new MessagingException("Unsupported protocol");
153        }
154        // defaults, which can be changed by security modifiers
155        int connectionSecurity = Transport.CONNECTION_SECURITY_NONE;
156        int defaultPort = 143;
157
158        // check for security flags and apply changes
159        if ((recvAuth.mFlags & HostAuth.FLAG_SSL) != 0) {
160            connectionSecurity = Transport.CONNECTION_SECURITY_SSL;
161            defaultPort = 993;
162        } else if ((recvAuth.mFlags & HostAuth.FLAG_TLS) != 0) {
163            connectionSecurity = Transport.CONNECTION_SECURITY_TLS;
164        }
165        boolean trustCertificates = ((recvAuth.mFlags & HostAuth.FLAG_TRUST_ALL) != 0);
166        int port = defaultPort;
167        if (recvAuth.mPort != HostAuth.PORT_UNKNOWN) {
168            port = recvAuth.mPort;
169        }
170        mRootTransport = new MailTransport("IMAP");
171        mRootTransport.setHost(recvAuth.mAddress);
172        mRootTransport.setPort(port);
173        mRootTransport.setSecurity(connectionSecurity, trustCertificates);
174
175        String[] userInfoParts = recvAuth.getLogin();
176        if (userInfoParts != null) {
177            mUsername = userInfoParts[0];
178            mPassword = userInfoParts[1];
179
180            // build the LOGIN string once (instead of over-and-over again.)
181            // apply the quoting here around the built-up password
182            mLoginPhrase = ImapConstants.LOGIN + " " + mUsername + " "
183                    + ImapUtility.imapQuoted(mPassword);
184        }
185        mPathPrefix = recvAuth.mDomain;
186    }
187
188    /* package */ Collection<ImapConnection> getConnectionPoolForTest() {
189        return mConnectionPool;
190    }
191
192    /**
193     * For testing only.  Injects a different root transport (it will be copied using
194     * newInstanceWithConfiguration() each time IMAP sets up a new channel).  The transport
195     * should already be set up and ready to use.  Do not use for real code.
196     * @param testTransport The Transport to inject and use for all future communication.
197     */
198    /* package */ void setTransport(Transport testTransport) {
199        mRootTransport = testTransport;
200    }
201
202    /**
203     * Return, or create and return, an string suitable for use in an IMAP ID message.
204     * This is constructed similarly to the way the browser sets up its user-agent strings.
205     * See RFC 2971 for more details.  The output of this command will be a series of key-value
206     * pairs delimited by spaces (there is no point in returning a structured result because
207     * this will be sent as-is to the IMAP server).  No tokens, parenthesis or "ID" are included,
208     * because some connections may append additional values.
209     *
210     * The following IMAP ID keys may be included:
211     *   name                   Android package name of the program
212     *   os                     "android"
213     *   os-version             "version; model; build-id"
214     *   vendor                 Vendor of the client/server
215     *   x-android-device-model Model (only revealed if release build)
216     *   x-android-net-operator Mobile network operator (if known)
217     *   AGUID                  A device+account UID
218     *
219     * In addition, a vendor policy .apk can append key/value pairs.
220     *
221     * @param userName the username of the account
222     * @param host the host (server) of the account
223     * @param capabilities a list of the capabilities from the server
224     * @return a String for use in an IMAP ID message.
225     */
226    @VisibleForTesting static String getImapId(Context context, String userName, String host,
227            String capabilities) {
228        // The first section is global to all IMAP connections, and generates the fixed
229        // values in any IMAP ID message
230        synchronized (ImapStore.class) {
231            if (sImapId == null) {
232                TelephonyManager tm =
233                        (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
234                String networkOperator = tm.getNetworkOperatorName();
235                if (networkOperator == null) networkOperator = "";
236
237                sImapId = makeCommonImapId(context.getPackageName(), Build.VERSION.RELEASE,
238                        Build.VERSION.CODENAME, Build.MODEL, Build.ID, Build.MANUFACTURER,
239                        networkOperator);
240            }
241        }
242
243        // This section is per Store, and adds in a dynamic elements like UID's.
244        // We don't cache the result of this work, because the caller does anyway.
245        StringBuilder id = new StringBuilder(sImapId);
246
247        // Optionally add any vendor-supplied id keys
248        String vendorId = VendorPolicyLoader.getInstance(context).getImapIdValues(userName, host,
249                capabilities);
250        if (vendorId != null) {
251            id.append(' ');
252            id.append(vendorId);
253        }
254
255        // Generate a UID that mixes a "stable" device UID with the email address
256        try {
257            String devUID = Preferences.getPreferences(context).getDeviceUID();
258            MessageDigest messageDigest;
259            messageDigest = MessageDigest.getInstance("SHA-1");
260            messageDigest.update(userName.getBytes());
261            messageDigest.update(devUID.getBytes());
262            byte[] uid = messageDigest.digest();
263            String hexUid = Base64.encodeToString(uid, Base64.NO_WRAP);
264            id.append(" \"AGUID\" \"");
265            id.append(hexUid);
266            id.append('\"');
267        } catch (NoSuchAlgorithmException e) {
268            Log.d(Logging.LOG_TAG, "couldn't obtain SHA-1 hash for device UID");
269        }
270        return id.toString();
271    }
272
273    /**
274     * Helper function that actually builds the static part of the IMAP ID string.  This is
275     * separated from getImapId for testability.  There is no escaping or encoding in IMAP ID so
276     * any rogue chars must be filtered here.
277     *
278     * @param packageName context.getPackageName()
279     * @param version Build.VERSION.RELEASE
280     * @param codeName Build.VERSION.CODENAME
281     * @param model Build.MODEL
282     * @param id Build.ID
283     * @param vendor Build.MANUFACTURER
284     * @param networkOperator TelephonyManager.getNetworkOperatorName()
285     * @return the static (never changes) portion of the IMAP ID
286     */
287    @VisibleForTesting static String makeCommonImapId(String packageName, String version,
288            String codeName, String model, String id, String vendor, String networkOperator) {
289
290        // Before building up IMAP ID string, pre-filter the input strings for "legal" chars
291        // This is using a fairly arbitrary char set intended to pass through most reasonable
292        // version, model, and vendor strings: a-z A-Z 0-9 - _ + = ; : . , / <space>
293        // The most important thing is *not* to pass parens, quotes, or CRLF, which would break
294        // the format of the IMAP ID list.
295        Pattern p = Pattern.compile("[^a-zA-Z0-9-_\\+=;:\\.,/ ]");
296        packageName = p.matcher(packageName).replaceAll("");
297        version = p.matcher(version).replaceAll("");
298        codeName = p.matcher(codeName).replaceAll("");
299        model = p.matcher(model).replaceAll("");
300        id = p.matcher(id).replaceAll("");
301        vendor = p.matcher(vendor).replaceAll("");
302        networkOperator = p.matcher(networkOperator).replaceAll("");
303
304        // "name" "com.android.email"
305        StringBuffer sb = new StringBuffer("\"name\" \"");
306        sb.append(packageName);
307        sb.append("\"");
308
309        // "os" "android"
310        sb.append(" \"os\" \"android\"");
311
312        // "os-version" "version; build-id"
313        sb.append(" \"os-version\" \"");
314        if (version.length() > 0) {
315            sb.append(version);
316        } else {
317            // default to "1.0"
318            sb.append("1.0");
319        }
320        // add the build ID or build #
321        if (id.length() > 0) {
322            sb.append("; ");
323            sb.append(id);
324        }
325        sb.append("\"");
326
327        // "vendor" "the vendor"
328        if (vendor.length() > 0) {
329            sb.append(" \"vendor\" \"");
330            sb.append(vendor);
331            sb.append("\"");
332        }
333
334        // "x-android-device-model" the device model (on release builds only)
335        if ("REL".equals(codeName)) {
336            if (model.length() > 0) {
337                sb.append(" \"x-android-device-model\" \"");
338                sb.append(model);
339                sb.append("\"");
340            }
341        }
342
343        // "x-android-mobile-net-operator" "name of network operator"
344        if (networkOperator.length() > 0) {
345            sb.append(" \"x-android-mobile-net-operator\" \"");
346            sb.append(networkOperator);
347            sb.append("\"");
348        }
349
350        return sb.toString();
351    }
352
353
354    @Override
355    public Folder getFolder(String name) {
356        ImapFolder folder;
357        synchronized (mFolderCache) {
358            folder = mFolderCache.get(name);
359            if (folder == null) {
360                folder = new ImapFolder(this, name);
361                mFolderCache.put(name, folder);
362            }
363        }
364        return folder;
365    }
366
367    /**
368     * Creates a mailbox hierarchy out of the flat data provided by the server.
369     */
370    @VisibleForTesting
371    static void createHierarchy(HashMap<String, ImapFolder> mailboxes) {
372        Set<String> pathnames = mailboxes.keySet();
373        for (String path : pathnames) {
374            final ImapFolder folder = mailboxes.get(path);
375            final Mailbox mailbox = folder.mMailbox;
376            int delimiterIdx = mailbox.mServerId.lastIndexOf(mailbox.mDelimiter);
377            long parentKey = -1L;
378            if (delimiterIdx != -1) {
379                String parentPath = path.substring(0, delimiterIdx);
380                final ImapFolder parentFolder = mailboxes.get(parentPath);
381                final Mailbox parentMailbox = (parentFolder == null) ? null : parentFolder.mMailbox;
382                if (parentMailbox != null) {
383                    parentKey = parentMailbox.mId;
384                    parentMailbox.mFlags
385                            |= (Mailbox.FLAG_HAS_CHILDREN | Mailbox.FLAG_CHILDREN_VISIBLE);
386                }
387            }
388            mailbox.mParentKey = parentKey;
389        }
390    }
391
392    /**
393     * Creates a {@link Folder} and associated {@link Mailbox}. If the folder does not already
394     * exist in the local database, a new row will immediately be created in the mailbox table.
395     * Otherwise, the existing row will be used. Any changes to existing rows, will not be stored
396     * to the database immediately.
397     * @param accountId The ID of the account the mailbox is to be associated with
398     * @param mailboxPath The path of the mailbox to add
399     * @param delimiter A path delimiter. May be {@code null} if there is no delimiter.
400     */
401    private ImapFolder addMailbox(Context context, long accountId, String mailboxPath,
402            char delimiter) {
403        ImapFolder folder = (ImapFolder) getFolder(mailboxPath);
404        Mailbox mailbox = getMailboxForPath(context, accountId, mailboxPath);
405        if (mailbox.isSaved()) {
406            // existing mailbox
407            // mailbox retrieved from database; save hash _before_ updating fields
408            folder.mHash = mailbox.getHashes();
409        }
410        updateMailbox(mailbox, accountId, mailboxPath, delimiter,
411                LegacyConversions.inferMailboxTypeFromName(context, mailboxPath));
412        if (folder.mHash == null) {
413            // new mailbox
414            // save hash after updating. allows tracking changes if the mailbox is saved
415            // outside of #saveMailboxList()
416            folder.mHash = mailbox.getHashes();
417            // We must save this here to make sure we have a valid ID for later
418            mailbox.save(mContext);
419        }
420        folder.mMailbox = mailbox;
421        return folder;
422    }
423
424    /**
425     * Persists the folders in the given list.
426     */
427    private static void saveMailboxList(Context context, HashMap<String, ImapFolder> folderMap) {
428        for (ImapFolder imapFolder : folderMap.values()) {
429            imapFolder.save(context);
430        }
431    }
432
433    @Override
434    public Folder[] updateFolders() throws MessagingException {
435        ImapConnection connection = getConnection();
436        try {
437            HashMap<String, ImapFolder> mailboxes = new HashMap<String, ImapFolder>();
438            // Establish a connection to the IMAP server; if necessary
439            // This ensures a valid prefix if the prefix is automatically set by the server
440            connection.executeSimpleCommand(ImapConstants.NOOP);
441            String imapCommand = ImapConstants.LIST + " \"\" \"*\"";
442            if (mPathPrefix != null) {
443                imapCommand = ImapConstants.LIST + " \"\" \"" + mPathPrefix + "*\"";
444            }
445            List<ImapResponse> responses = connection.executeSimpleCommand(imapCommand);
446            for (ImapResponse response : responses) {
447                // S: * LIST (\Noselect) "/" ~/Mail/foo
448                if (response.isDataResponse(0, ImapConstants.LIST)) {
449                    boolean includeFolder = true;
450
451                    // Get folder name.
452                    ImapString encodedFolder = response.getStringOrEmpty(3);
453                    if (encodedFolder.isEmpty()) continue;
454                    String folderName = decodeFolderName(encodedFolder.getString(), mPathPrefix);
455                    if (ImapConstants.INBOX.equalsIgnoreCase(folderName)) {
456                        continue;
457                    }
458
459                    // Parse attributes.
460                    if (response.getListOrEmpty(1).contains(ImapConstants.FLAG_NO_SELECT)) {
461                        includeFolder = false;
462                    }
463                    if (includeFolder) {
464                        String delimiter = response.getStringOrEmpty(2).getString();
465                        char delimiterChar = '\0';
466                        if (!TextUtils.isEmpty(delimiter)) {
467                            delimiterChar = delimiter.charAt(0);
468                        }
469                        ImapFolder folder =
470                                addMailbox(mContext, mAccount.mId, folderName, delimiterChar);
471                        mailboxes.put(folderName, folder);
472                    }
473                }
474            }
475            Folder newFolder = addMailbox(mContext, mAccount.mId, ImapConstants.INBOX, '\0');
476            mailboxes.put(ImapConstants.INBOX, (ImapFolder)newFolder);
477            createHierarchy(mailboxes);
478            saveMailboxList(mContext, mailboxes);
479            return mailboxes.values().toArray(new Folder[] {});
480        } catch (IOException ioe) {
481            connection.close();
482            throw new MessagingException("Unable to get folder list.", ioe);
483        } catch (AuthenticationFailedException afe) {
484            // We do NOT want this connection pooled, or we will continue to send NOOP and SELECT
485            // commands to the server
486            connection.destroyResponses();
487            connection = null;
488            throw afe;
489        } finally {
490            if (connection != null) {
491                connection.destroyResponses();
492                poolConnection(connection);
493            }
494        }
495    }
496
497    @Override
498    public Bundle checkSettings() throws MessagingException {
499        int result = MessagingException.NO_ERROR;
500        Bundle bundle = new Bundle();
501        ImapConnection connection = new ImapConnection();
502        try {
503            connection.open();
504            connection.close();
505        } catch (IOException ioe) {
506            bundle.putString(EmailServiceProxy.VALIDATE_BUNDLE_ERROR_MESSAGE, ioe.getMessage());
507            result = MessagingException.IOERROR;
508        } finally {
509            connection.destroyResponses();
510        }
511        bundle.putInt(EmailServiceProxy.VALIDATE_BUNDLE_RESULT_CODE, result);
512        return bundle;
513    }
514
515    /**
516     * Fixes the path prefix, if necessary. The path prefix must always end with the
517     * path separator.
518     */
519    /*package*/ void ensurePrefixIsValid() {
520        // Make sure the path prefix ends with the path separator
521        if (!TextUtils.isEmpty(mPathPrefix) && !TextUtils.isEmpty(mPathSeparator)) {
522            if (!mPathPrefix.endsWith(mPathSeparator)) {
523                mPathPrefix = mPathPrefix + mPathSeparator;
524            }
525        }
526    }
527
528    /**
529     * Gets a connection if one is available from the pool, or creates a new one if not.
530     */
531    /* package */ ImapConnection getConnection() {
532        ImapConnection connection = null;
533        while ((connection = mConnectionPool.poll()) != null) {
534            try {
535                connection.executeSimpleCommand(ImapConstants.NOOP);
536                break;
537            } catch (MessagingException e) {
538                // Fall through
539            } catch (IOException e) {
540                // Fall through
541            } finally {
542                connection.destroyResponses();
543            }
544            connection.close();
545            connection = null;
546        }
547        if (connection == null) {
548            connection = new ImapConnection();
549        }
550        return connection;
551    }
552
553    /**
554     * Save a {@link ImapConnection} in the pool for reuse.
555     */
556    /* package */ void poolConnection(ImapConnection connection) {
557        if (connection != null) {
558            mConnectionPool.add(connection);
559        }
560    }
561
562    /**
563     * Prepends the folder name with the given prefix and UTF-7 encodes it.
564     */
565    /* package */ static String encodeFolderName(String name, String prefix) {
566        // do NOT add the prefix to the special name "INBOX"
567        if (ImapConstants.INBOX.equalsIgnoreCase(name)) return name;
568
569        // Prepend prefix
570        if (prefix != null) {
571            name = prefix + name;
572        }
573
574        // TODO bypass the conversion if name doesn't have special char.
575        ByteBuffer bb = MODIFIED_UTF_7_CHARSET.encode(name);
576        byte[] b = new byte[bb.limit()];
577        bb.get(b);
578
579        return Utility.fromAscii(b);
580    }
581
582    /**
583     * UTF-7 decodes the folder name and removes the given path prefix.
584     */
585    /* package */ static String decodeFolderName(String name, String prefix) {
586        // TODO bypass the conversion if name doesn't have special char.
587        String folder;
588        folder = MODIFIED_UTF_7_CHARSET.decode(ByteBuffer.wrap(Utility.toAscii(name))).toString();
589        if ((prefix != null) && folder.startsWith(prefix)) {
590            folder = folder.substring(prefix.length());
591        }
592        return folder;
593    }
594
595    /**
596     * Returns UIDs of Messages joined with "," as the separator.
597     */
598    /* package */ static String joinMessageUids(Message[] messages) {
599        StringBuilder sb = new StringBuilder();
600        boolean notFirst = false;
601        for (Message m : messages) {
602            if (notFirst) {
603                sb.append(',');
604            }
605            sb.append(m.getUid());
606            notFirst = true;
607        }
608        return sb.toString();
609    }
610
611    /**
612     * A cacheable class that stores the details for a single IMAP connection.
613     */
614    class ImapConnection {
615        /** ID capability per RFC 2971*/
616        public static final int CAPABILITY_ID        = 1 << 0;
617        /** NAMESPACE capability per RFC 2342 */
618        public static final int CAPABILITY_NAMESPACE = 1 << 1;
619        /** STARTTLS capability per RFC 3501 */
620        public static final int CAPABILITY_STARTTLS  = 1 << 2;
621        /** UIDPLUS capability per RFC 4315 */
622        public static final int CAPABILITY_UIDPLUS   = 1 << 3;
623
624        /** The capabilities supported; a set of CAPABILITY_* values. */
625        private int mCapabilities;
626        private static final String IMAP_DEDACTED_LOG = "[IMAP command redacted]";
627        Transport mTransport;
628        private ImapResponseParser mParser;
629        /** # of command/response lines to log upon crash. */
630        private static final int DISCOURSE_LOGGER_SIZE = 64;
631        private final DiscourseLogger mDiscourse = new DiscourseLogger(DISCOURSE_LOGGER_SIZE);
632
633        public void open() throws IOException, MessagingException {
634            if (mTransport != null && mTransport.isOpen()) {
635                return;
636            }
637
638            try {
639                // copy configuration into a clean transport, if necessary
640                if (mTransport == null) {
641                    mTransport = mRootTransport.newInstanceWithConfiguration();
642                }
643
644                mTransport.open();
645                mTransport.setSoTimeout(MailTransport.SOCKET_READ_TIMEOUT);
646
647                createParser();
648
649                // BANNER
650                mParser.readResponse();
651
652                // CAPABILITY
653                ImapResponse capabilities = queryCapabilities();
654
655                boolean hasStartTlsCapability =
656                    capabilities.contains(ImapConstants.STARTTLS);
657
658                // TLS
659                ImapResponse newCapabilities = doStartTls(hasStartTlsCapability);
660                if (newCapabilities != null) {
661                    capabilities = newCapabilities;
662                }
663
664                // NOTE: An IMAP response MUST be processed before issuing any new IMAP
665                // requests. Subsequent requests may destroy previous response data. As
666                // such, we save away capability information here for future use.
667                setCapabilities(capabilities);
668                String capabilityString = capabilities.flatten();
669
670                // ID
671                doSendId(isCapable(CAPABILITY_ID), capabilityString);
672
673                // LOGIN
674                doLogin();
675
676                // NAMESPACE (only valid in the Authenticated state)
677                doGetNamespace(isCapable(CAPABILITY_NAMESPACE));
678
679                // Gets the path separator from the server
680                doGetPathSeparator();
681
682                ensurePrefixIsValid();
683            } catch (SSLException e) {
684                if (Email.DEBUG) {
685                    Log.d(Logging.LOG_TAG, e.toString());
686                }
687                throw new CertificateValidationException(e.getMessage(), e);
688            } catch (IOException ioe) {
689                // NOTE:  Unlike similar code in POP3, I'm going to rethrow as-is.  There is a lot
690                // of other code here that catches IOException and I don't want to break it.
691                // This catch is only here to enhance logging of connection-time issues.
692                if (Email.DEBUG) {
693                    Log.d(Logging.LOG_TAG, ioe.toString());
694                }
695                throw ioe;
696            } finally {
697                destroyResponses();
698            }
699        }
700
701        public void close() {
702            if (mTransport != null) {
703                mTransport.close();
704                mTransport = null;
705            }
706        }
707
708        /**
709         * Returns whether or not the specified capability is supported by the server.
710         */
711        public boolean isCapable(int capability) {
712            return (mCapabilities & capability) != 0;
713        }
714
715        /**
716         * Sets the capability flags according to the response provided by the server.
717         * Note: We only set the capability flags that we are interested in. There are many IMAP
718         * capabilities that we do not track.
719         */
720        private void setCapabilities(ImapResponse capabilities) {
721            if (capabilities.contains(ImapConstants.ID)) {
722                mCapabilities |= CAPABILITY_ID;
723            }
724            if (capabilities.contains(ImapConstants.NAMESPACE)) {
725                mCapabilities |= CAPABILITY_NAMESPACE;
726            }
727            if (capabilities.contains(ImapConstants.UIDPLUS)) {
728                mCapabilities |= CAPABILITY_UIDPLUS;
729            }
730            if (capabilities.contains(ImapConstants.STARTTLS)) {
731                mCapabilities |= CAPABILITY_STARTTLS;
732            }
733        }
734
735        /**
736         * Create an {@link ImapResponseParser} from {@code mTransport.getInputStream()} and
737         * set it to {@link #mParser}.
738         *
739         * If we already have an {@link ImapResponseParser}, we
740         * {@link #destroyResponses()} and throw it away.
741         */
742        private void createParser() {
743            destroyResponses();
744            mParser = new ImapResponseParser(mTransport.getInputStream(), mDiscourse);
745        }
746
747        public void destroyResponses() {
748            if (mParser != null) {
749                mParser.destroyResponses();
750            }
751        }
752
753        /* package */ boolean isTransportOpenForTest() {
754            return mTransport != null ? mTransport.isOpen() : false;
755        }
756
757        public ImapResponse readResponse() throws IOException, MessagingException {
758            return mParser.readResponse();
759        }
760
761        /**
762         * Send a single command to the server.  The command will be preceded by an IMAP command
763         * tag and followed by \r\n (caller need not supply them).
764         *
765         * @param command The command to send to the server
766         * @param sensitive If true, the command will not be logged
767         * @return Returns the command tag that was sent
768         */
769        public String sendCommand(String command, boolean sensitive)
770            throws MessagingException, IOException {
771            open();
772            String tag = Integer.toString(mNextCommandTag.incrementAndGet());
773            String commandToSend = tag + " " + command;
774            mTransport.writeLine(commandToSend, sensitive ? IMAP_DEDACTED_LOG : null);
775            mDiscourse.addSentCommand(sensitive ? IMAP_DEDACTED_LOG : commandToSend);
776            return tag;
777        }
778
779        /*package*/ List<ImapResponse> executeSimpleCommand(String command) throws IOException,
780                MessagingException {
781            return executeSimpleCommand(command, false);
782        }
783
784        /*package*/ List<ImapResponse> executeSimpleCommand(String command, boolean sensitive)
785                throws IOException, MessagingException {
786            String tag = sendCommand(command, sensitive);
787            ArrayList<ImapResponse> responses = new ArrayList<ImapResponse>();
788            ImapResponse response;
789            do {
790                response = mParser.readResponse();
791                responses.add(response);
792            } while (!response.isTagged());
793            if (!response.isOk()) {
794                final String toString = response.toString();
795                final String alert = response.getAlertTextOrEmpty().getString();
796                destroyResponses();
797                throw new ImapException(toString, alert);
798            }
799            return responses;
800        }
801
802        /**
803         * Query server for capabilities.
804         */
805        private ImapResponse queryCapabilities() throws IOException, MessagingException {
806            ImapResponse capabilityResponse = null;
807            for (ImapResponse r : executeSimpleCommand(ImapConstants.CAPABILITY)) {
808                if (r.is(0, ImapConstants.CAPABILITY)) {
809                    capabilityResponse = r;
810                    break;
811                }
812            }
813            if (capabilityResponse == null) {
814                throw new MessagingException("Invalid CAPABILITY response received");
815            }
816            return capabilityResponse;
817        }
818
819        /**
820         * Sends client identification information to the IMAP server per RFC 2971. If
821         * the server does not support the ID command, this will perform no operation.
822         *
823         * Interoperability hack:  Never send ID to *.secureserver.net, which sends back a
824         * malformed response that our parser can't deal with.
825         */
826        private void doSendId(boolean hasIdCapability, String capabilities)
827                throws MessagingException {
828            if (!hasIdCapability) return;
829
830            // Never send ID to *.secureserver.net
831            String host = mRootTransport.getHost();
832            if (host.toLowerCase().endsWith(".secureserver.net")) return;
833
834            // Assign user-agent string (for RFC2971 ID command)
835            String mUserAgent = getImapId(mContext, mUsername, host, capabilities);
836
837            if (mUserAgent != null) {
838                mIdPhrase = ImapConstants.ID + " (" + mUserAgent + ")";
839            } else if (DEBUG_FORCE_SEND_ID) {
840                mIdPhrase = ImapConstants.ID + " " + ImapConstants.NIL;
841            }
842            // else: mIdPhrase = null, no ID will be emitted
843
844            // Send user-agent in an RFC2971 ID command
845            if (mIdPhrase != null) {
846                try {
847                    executeSimpleCommand(mIdPhrase);
848                } catch (ImapException ie) {
849                    // Log for debugging, but this is not a fatal problem.
850                    if (Email.DEBUG) {
851                        Log.d(Logging.LOG_TAG, ie.toString());
852                    }
853                } catch (IOException ioe) {
854                    // Special case to handle malformed OK responses and ignore them.
855                    // A true IOException will recur on the following login steps
856                    // This can go away after the parser is fixed - see bug 2138981
857                }
858            }
859        }
860
861        /**
862         * Gets the user's Personal Namespace from the IMAP server per RFC 2342. If the user
863         * explicitly sets a namespace (using setup UI) or if the server does not support the
864         * namespace command, this will perform no operation.
865         */
866        private void doGetNamespace(boolean hasNamespaceCapability) throws MessagingException {
867            // user did not specify a hard-coded prefix; try to get it from the server
868            if (hasNamespaceCapability && TextUtils.isEmpty(mPathPrefix)) {
869                List<ImapResponse> responseList = Collections.emptyList();
870
871                try {
872                    responseList = executeSimpleCommand(ImapConstants.NAMESPACE);
873                } catch (ImapException ie) {
874                    // Log for debugging, but this is not a fatal problem.
875                    if (Email.DEBUG) {
876                        Log.d(Logging.LOG_TAG, ie.toString());
877                    }
878                } catch (IOException ioe) {
879                    // Special case to handle malformed OK responses and ignore them.
880                }
881
882                for (ImapResponse response: responseList) {
883                    if (response.isDataResponse(0, ImapConstants.NAMESPACE)) {
884                        ImapList namespaceList = response.getListOrEmpty(1);
885                        ImapList namespace = namespaceList.getListOrEmpty(0);
886                        String namespaceString = namespace.getStringOrEmpty(0).getString();
887                        if (!TextUtils.isEmpty(namespaceString)) {
888                            mPathPrefix = decodeFolderName(namespaceString, null);
889                            mPathSeparator = namespace.getStringOrEmpty(1).getString();
890                        }
891                    }
892                }
893            }
894        }
895
896        /**
897         * Logs into the IMAP server
898         */
899        private void doLogin()
900                throws IOException, MessagingException, AuthenticationFailedException {
901            try {
902                // TODO eventually we need to add additional authentication
903                // options such as SASL
904                executeSimpleCommand(mLoginPhrase, true);
905            } catch (ImapException ie) {
906                if (Email.DEBUG) {
907                    Log.d(Logging.LOG_TAG, ie.toString());
908                }
909                throw new AuthenticationFailedException(ie.getAlertText(), ie);
910
911            } catch (MessagingException me) {
912                throw new AuthenticationFailedException(null, me);
913            }
914        }
915
916        /**
917         * Gets the path separator per the LIST command in RFC 3501. If the path separator
918         * was obtained while obtaining the namespace or there is no prefix defined, this
919         * will perform no operation.
920         */
921        private void doGetPathSeparator() throws MessagingException {
922            // user did not specify a hard-coded prefix; try to get it from the server
923            if (TextUtils.isEmpty(mPathSeparator) && !TextUtils.isEmpty(mPathPrefix)) {
924                List<ImapResponse> responseList = Collections.emptyList();
925
926                try {
927                    responseList = executeSimpleCommand(ImapConstants.LIST + " \"\" \"\"");
928                } catch (ImapException ie) {
929                    // Log for debugging, but this is not a fatal problem.
930                    if (Email.DEBUG) {
931                        Log.d(Logging.LOG_TAG, ie.toString());
932                    }
933                } catch (IOException ioe) {
934                    // Special case to handle malformed OK responses and ignore them.
935                }
936
937                for (ImapResponse response: responseList) {
938                    if (response.isDataResponse(0, ImapConstants.LIST)) {
939                        mPathSeparator = response.getStringOrEmpty(2).getString();
940                    }
941                }
942            }
943        }
944
945        /**
946         * Starts a TLS session with the IMAP server per RFC 3501. If the user has not opted
947         * to use TLS or the server does not support the TLS capability, this will perform
948         * no operation.
949         */
950        private ImapResponse doStartTls(boolean hasStartTlsCapability)
951                throws IOException, MessagingException {
952            if (mTransport.canTryTlsSecurity()) {
953                if (hasStartTlsCapability) {
954                    // STARTTLS
955                    executeSimpleCommand(ImapConstants.STARTTLS);
956
957                    mTransport.reopenTls();
958                    mTransport.setSoTimeout(MailTransport.SOCKET_READ_TIMEOUT);
959                    createParser();
960                    // Per RFC requirement (3501-6.2.1) gather new capabilities
961                    return(queryCapabilities());
962                } else {
963                    if (Email.DEBUG) {
964                        Log.d(Logging.LOG_TAG, "TLS not supported but required");
965                    }
966                    throw new MessagingException(MessagingException.TLS_REQUIRED);
967                }
968            }
969            return null;
970        }
971
972        /** @see DiscourseLogger#logLastDiscourse() */
973        public void logLastDiscourse() {
974            mDiscourse.logLastDiscourse();
975        }
976    }
977
978    static class ImapMessage extends MimeMessage {
979        ImapMessage(String uid, ImapFolder folder) {
980            this.mUid = uid;
981            this.mFolder = folder;
982        }
983
984        public void setSize(int size) {
985            this.mSize = size;
986        }
987
988        @Override
989        public void parse(InputStream in) throws IOException, MessagingException {
990            super.parse(in);
991        }
992
993        public void setFlagInternal(Flag flag, boolean set) throws MessagingException {
994            super.setFlag(flag, set);
995        }
996
997        @Override
998        public void setFlag(Flag flag, boolean set) throws MessagingException {
999            super.setFlag(flag, set);
1000            mFolder.setFlags(new Message[] { this }, new Flag[] { flag }, set);
1001        }
1002    }
1003
1004    static class ImapException extends MessagingException {
1005        private static final long serialVersionUID = 1L;
1006
1007        String mAlertText;
1008
1009        public ImapException(String message, String alertText, Throwable throwable) {
1010            super(message, throwable);
1011            this.mAlertText = alertText;
1012        }
1013
1014        public ImapException(String message, String alertText) {
1015            super(message);
1016            this.mAlertText = alertText;
1017        }
1018
1019        public String getAlertText() {
1020            return mAlertText;
1021        }
1022
1023        public void setAlertText(String alertText) {
1024            mAlertText = alertText;
1025        }
1026    }
1027}
1028