ImapStore.java revision 019341af98ffe2dcd484bd0468c9858d9e7cd7a3
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     * @param selectable If {@code true}, the mailbox can be selected and used to store messages.
401     */
402    private ImapFolder addMailbox(Context context, long accountId, String mailboxPath,
403            char delimiter, boolean selectable) {
404        ImapFolder folder = (ImapFolder) getFolder(mailboxPath);
405        Mailbox mailbox = getMailboxForPath(context, accountId, mailboxPath);
406        if (mailbox.isSaved()) {
407            // existing mailbox
408            // mailbox retrieved from database; save hash _before_ updating fields
409            folder.mHash = mailbox.getHashes();
410        }
411        updateMailbox(mailbox, accountId, mailboxPath, delimiter, selectable,
412                LegacyConversions.inferMailboxTypeFromName(context, mailboxPath));
413        if (folder.mHash == null) {
414            // new mailbox
415            // save hash after updating. allows tracking changes if the mailbox is saved
416            // outside of #saveMailboxList()
417            folder.mHash = mailbox.getHashes();
418            // We must save this here to make sure we have a valid ID for later
419            mailbox.save(mContext);
420        }
421        folder.mMailbox = mailbox;
422        return folder;
423    }
424
425    /**
426     * Persists the folders in the given list.
427     */
428    private static void saveMailboxList(Context context, HashMap<String, ImapFolder> folderMap) {
429        for (ImapFolder imapFolder : folderMap.values()) {
430            imapFolder.save(context);
431        }
432    }
433
434    @Override
435    public Folder[] updateFolders() throws MessagingException {
436        ImapConnection connection = getConnection();
437        try {
438            HashMap<String, ImapFolder> mailboxes = new HashMap<String, ImapFolder>();
439            // Establish a connection to the IMAP server; if necessary
440            // This ensures a valid prefix if the prefix is automatically set by the server
441            connection.executeSimpleCommand(ImapConstants.NOOP);
442            String imapCommand = ImapConstants.LIST + " \"\" \"*\"";
443            if (mPathPrefix != null) {
444                imapCommand = ImapConstants.LIST + " \"\" \"" + mPathPrefix + "*\"";
445            }
446            List<ImapResponse> responses = connection.executeSimpleCommand(imapCommand);
447            for (ImapResponse response : responses) {
448                // S: * LIST (\Noselect) "/" ~/Mail/foo
449                if (response.isDataResponse(0, ImapConstants.LIST)) {
450                    // Get folder name.
451                    ImapString encodedFolder = response.getStringOrEmpty(3);
452                    if (encodedFolder.isEmpty()) continue;
453
454                    String folderName = decodeFolderName(encodedFolder.getString(), mPathPrefix);
455                    if (ImapConstants.INBOX.equalsIgnoreCase(folderName)) continue;
456
457                    // Parse attributes.
458                    boolean selectable =
459                        !response.getListOrEmpty(1).contains(ImapConstants.FLAG_NO_SELECT);
460                    String delimiter = response.getStringOrEmpty(2).getString();
461                    char delimiterChar = '\0';
462                    if (!TextUtils.isEmpty(delimiter)) {
463                        delimiterChar = delimiter.charAt(0);
464                    }
465                    ImapFolder folder =
466                        addMailbox(mContext, mAccount.mId, folderName, delimiterChar, selectable);
467                    mailboxes.put(folderName, folder);
468                }
469            }
470            Folder newFolder =
471                addMailbox(mContext, mAccount.mId, ImapConstants.INBOX, '\0', true /*selectable*/);
472            mailboxes.put(ImapConstants.INBOX, (ImapFolder)newFolder);
473            createHierarchy(mailboxes);
474            saveMailboxList(mContext, mailboxes);
475            return mailboxes.values().toArray(new Folder[] {});
476        } catch (IOException ioe) {
477            connection.close();
478            throw new MessagingException("Unable to get folder list.", ioe);
479        } catch (AuthenticationFailedException afe) {
480            // We do NOT want this connection pooled, or we will continue to send NOOP and SELECT
481            // commands to the server
482            connection.destroyResponses();
483            connection = null;
484            throw afe;
485        } finally {
486            if (connection != null) {
487                connection.destroyResponses();
488                poolConnection(connection);
489            }
490        }
491    }
492
493    @Override
494    public Bundle checkSettings() throws MessagingException {
495        int result = MessagingException.NO_ERROR;
496        Bundle bundle = new Bundle();
497        ImapConnection connection = new ImapConnection();
498        try {
499            connection.open();
500            connection.close();
501        } catch (IOException ioe) {
502            bundle.putString(EmailServiceProxy.VALIDATE_BUNDLE_ERROR_MESSAGE, ioe.getMessage());
503            result = MessagingException.IOERROR;
504        } finally {
505            connection.destroyResponses();
506        }
507        bundle.putInt(EmailServiceProxy.VALIDATE_BUNDLE_RESULT_CODE, result);
508        return bundle;
509    }
510
511    /**
512     * Fixes the path prefix, if necessary. The path prefix must always end with the
513     * path separator.
514     */
515    /*package*/ void ensurePrefixIsValid() {
516        // Make sure the path prefix ends with the path separator
517        if (!TextUtils.isEmpty(mPathPrefix) && !TextUtils.isEmpty(mPathSeparator)) {
518            if (!mPathPrefix.endsWith(mPathSeparator)) {
519                mPathPrefix = mPathPrefix + mPathSeparator;
520            }
521        }
522    }
523
524    /**
525     * Gets a connection if one is available from the pool, or creates a new one if not.
526     */
527    /* package */ ImapConnection getConnection() {
528        ImapConnection connection = null;
529        while ((connection = mConnectionPool.poll()) != null) {
530            try {
531                connection.executeSimpleCommand(ImapConstants.NOOP);
532                break;
533            } catch (MessagingException e) {
534                // Fall through
535            } catch (IOException e) {
536                // Fall through
537            } finally {
538                connection.destroyResponses();
539            }
540            connection.close();
541            connection = null;
542        }
543        if (connection == null) {
544            connection = new ImapConnection();
545        }
546        return connection;
547    }
548
549    /**
550     * Save a {@link ImapConnection} in the pool for reuse.
551     */
552    /* package */ void poolConnection(ImapConnection connection) {
553        if (connection != null) {
554            mConnectionPool.add(connection);
555        }
556    }
557
558    /**
559     * Prepends the folder name with the given prefix and UTF-7 encodes it.
560     */
561    /* package */ static String encodeFolderName(String name, String prefix) {
562        // do NOT add the prefix to the special name "INBOX"
563        if (ImapConstants.INBOX.equalsIgnoreCase(name)) return name;
564
565        // Prepend prefix
566        if (prefix != null) {
567            name = prefix + name;
568        }
569
570        // TODO bypass the conversion if name doesn't have special char.
571        ByteBuffer bb = MODIFIED_UTF_7_CHARSET.encode(name);
572        byte[] b = new byte[bb.limit()];
573        bb.get(b);
574
575        return Utility.fromAscii(b);
576    }
577
578    /**
579     * UTF-7 decodes the folder name and removes the given path prefix.
580     */
581    /* package */ static String decodeFolderName(String name, String prefix) {
582        // TODO bypass the conversion if name doesn't have special char.
583        String folder;
584        folder = MODIFIED_UTF_7_CHARSET.decode(ByteBuffer.wrap(Utility.toAscii(name))).toString();
585        if ((prefix != null) && folder.startsWith(prefix)) {
586            folder = folder.substring(prefix.length());
587        }
588        return folder;
589    }
590
591    /**
592     * Returns UIDs of Messages joined with "," as the separator.
593     */
594    /* package */ static String joinMessageUids(Message[] messages) {
595        StringBuilder sb = new StringBuilder();
596        boolean notFirst = false;
597        for (Message m : messages) {
598            if (notFirst) {
599                sb.append(',');
600            }
601            sb.append(m.getUid());
602            notFirst = true;
603        }
604        return sb.toString();
605    }
606
607    /**
608     * A cacheable class that stores the details for a single IMAP connection.
609     */
610    class ImapConnection {
611        /** ID capability per RFC 2971*/
612        public static final int CAPABILITY_ID        = 1 << 0;
613        /** NAMESPACE capability per RFC 2342 */
614        public static final int CAPABILITY_NAMESPACE = 1 << 1;
615        /** STARTTLS capability per RFC 3501 */
616        public static final int CAPABILITY_STARTTLS  = 1 << 2;
617        /** UIDPLUS capability per RFC 4315 */
618        public static final int CAPABILITY_UIDPLUS   = 1 << 3;
619
620        /** The capabilities supported; a set of CAPABILITY_* values. */
621        private int mCapabilities;
622        private static final String IMAP_DEDACTED_LOG = "[IMAP command redacted]";
623        Transport mTransport;
624        private ImapResponseParser mParser;
625        /** # of command/response lines to log upon crash. */
626        private static final int DISCOURSE_LOGGER_SIZE = 64;
627        private final DiscourseLogger mDiscourse = new DiscourseLogger(DISCOURSE_LOGGER_SIZE);
628
629        public void open() throws IOException, MessagingException {
630            if (mTransport != null && mTransport.isOpen()) {
631                return;
632            }
633
634            try {
635                // copy configuration into a clean transport, if necessary
636                if (mTransport == null) {
637                    mTransport = mRootTransport.newInstanceWithConfiguration();
638                }
639
640                mTransport.open();
641                mTransport.setSoTimeout(MailTransport.SOCKET_READ_TIMEOUT);
642
643                createParser();
644
645                // BANNER
646                mParser.readResponse();
647
648                // CAPABILITY
649                ImapResponse capabilities = queryCapabilities();
650
651                boolean hasStartTlsCapability =
652                    capabilities.contains(ImapConstants.STARTTLS);
653
654                // TLS
655                ImapResponse newCapabilities = doStartTls(hasStartTlsCapability);
656                if (newCapabilities != null) {
657                    capabilities = newCapabilities;
658                }
659
660                // NOTE: An IMAP response MUST be processed before issuing any new IMAP
661                // requests. Subsequent requests may destroy previous response data. As
662                // such, we save away capability information here for future use.
663                setCapabilities(capabilities);
664                String capabilityString = capabilities.flatten();
665
666                // ID
667                doSendId(isCapable(CAPABILITY_ID), capabilityString);
668
669                // LOGIN
670                doLogin();
671
672                // NAMESPACE (only valid in the Authenticated state)
673                doGetNamespace(isCapable(CAPABILITY_NAMESPACE));
674
675                // Gets the path separator from the server
676                doGetPathSeparator();
677
678                ensurePrefixIsValid();
679            } catch (SSLException e) {
680                if (Email.DEBUG) {
681                    Log.d(Logging.LOG_TAG, e.toString());
682                }
683                throw new CertificateValidationException(e.getMessage(), e);
684            } catch (IOException ioe) {
685                // NOTE:  Unlike similar code in POP3, I'm going to rethrow as-is.  There is a lot
686                // of other code here that catches IOException and I don't want to break it.
687                // This catch is only here to enhance logging of connection-time issues.
688                if (Email.DEBUG) {
689                    Log.d(Logging.LOG_TAG, ioe.toString());
690                }
691                throw ioe;
692            } finally {
693                destroyResponses();
694            }
695        }
696
697        public void close() {
698            if (mTransport != null) {
699                mTransport.close();
700                mTransport = null;
701            }
702        }
703
704        /**
705         * Returns whether or not the specified capability is supported by the server.
706         */
707        public boolean isCapable(int capability) {
708            return (mCapabilities & capability) != 0;
709        }
710
711        /**
712         * Sets the capability flags according to the response provided by the server.
713         * Note: We only set the capability flags that we are interested in. There are many IMAP
714         * capabilities that we do not track.
715         */
716        private void setCapabilities(ImapResponse capabilities) {
717            if (capabilities.contains(ImapConstants.ID)) {
718                mCapabilities |= CAPABILITY_ID;
719            }
720            if (capabilities.contains(ImapConstants.NAMESPACE)) {
721                mCapabilities |= CAPABILITY_NAMESPACE;
722            }
723            if (capabilities.contains(ImapConstants.UIDPLUS)) {
724                mCapabilities |= CAPABILITY_UIDPLUS;
725            }
726            if (capabilities.contains(ImapConstants.STARTTLS)) {
727                mCapabilities |= CAPABILITY_STARTTLS;
728            }
729        }
730
731        /**
732         * Create an {@link ImapResponseParser} from {@code mTransport.getInputStream()} and
733         * set it to {@link #mParser}.
734         *
735         * If we already have an {@link ImapResponseParser}, we
736         * {@link #destroyResponses()} and throw it away.
737         */
738        private void createParser() {
739            destroyResponses();
740            mParser = new ImapResponseParser(mTransport.getInputStream(), mDiscourse);
741        }
742
743        public void destroyResponses() {
744            if (mParser != null) {
745                mParser.destroyResponses();
746            }
747        }
748
749        /* package */ boolean isTransportOpenForTest() {
750            return mTransport != null ? mTransport.isOpen() : false;
751        }
752
753        public ImapResponse readResponse() throws IOException, MessagingException {
754            return mParser.readResponse();
755        }
756
757        /**
758         * Send a single command to the server.  The command will be preceded by an IMAP command
759         * tag and followed by \r\n (caller need not supply them).
760         *
761         * @param command The command to send to the server
762         * @param sensitive If true, the command will not be logged
763         * @return Returns the command tag that was sent
764         */
765        public String sendCommand(String command, boolean sensitive)
766            throws MessagingException, IOException {
767            open();
768            String tag = Integer.toString(mNextCommandTag.incrementAndGet());
769            String commandToSend = tag + " " + command;
770            mTransport.writeLine(commandToSend, sensitive ? IMAP_DEDACTED_LOG : null);
771            mDiscourse.addSentCommand(sensitive ? IMAP_DEDACTED_LOG : commandToSend);
772            return tag;
773        }
774
775        /*package*/ List<ImapResponse> executeSimpleCommand(String command) throws IOException,
776                MessagingException {
777            return executeSimpleCommand(command, false);
778        }
779
780        /*package*/ List<ImapResponse> executeSimpleCommand(String command, boolean sensitive)
781                throws IOException, MessagingException {
782            String tag = sendCommand(command, sensitive);
783            ArrayList<ImapResponse> responses = new ArrayList<ImapResponse>();
784            ImapResponse response;
785            do {
786                response = mParser.readResponse();
787                responses.add(response);
788            } while (!response.isTagged());
789            if (!response.isOk()) {
790                final String toString = response.toString();
791                final String alert = response.getAlertTextOrEmpty().getString();
792                destroyResponses();
793                throw new ImapException(toString, alert);
794            }
795            return responses;
796        }
797
798        /**
799         * Query server for capabilities.
800         */
801        private ImapResponse queryCapabilities() throws IOException, MessagingException {
802            ImapResponse capabilityResponse = null;
803            for (ImapResponse r : executeSimpleCommand(ImapConstants.CAPABILITY)) {
804                if (r.is(0, ImapConstants.CAPABILITY)) {
805                    capabilityResponse = r;
806                    break;
807                }
808            }
809            if (capabilityResponse == null) {
810                throw new MessagingException("Invalid CAPABILITY response received");
811            }
812            return capabilityResponse;
813        }
814
815        /**
816         * Sends client identification information to the IMAP server per RFC 2971. If
817         * the server does not support the ID command, this will perform no operation.
818         *
819         * Interoperability hack:  Never send ID to *.secureserver.net, which sends back a
820         * malformed response that our parser can't deal with.
821         */
822        private void doSendId(boolean hasIdCapability, String capabilities)
823                throws MessagingException {
824            if (!hasIdCapability) return;
825
826            // Never send ID to *.secureserver.net
827            String host = mRootTransport.getHost();
828            if (host.toLowerCase().endsWith(".secureserver.net")) return;
829
830            // Assign user-agent string (for RFC2971 ID command)
831            String mUserAgent = getImapId(mContext, mUsername, host, capabilities);
832
833            if (mUserAgent != null) {
834                mIdPhrase = ImapConstants.ID + " (" + mUserAgent + ")";
835            } else if (DEBUG_FORCE_SEND_ID) {
836                mIdPhrase = ImapConstants.ID + " " + ImapConstants.NIL;
837            }
838            // else: mIdPhrase = null, no ID will be emitted
839
840            // Send user-agent in an RFC2971 ID command
841            if (mIdPhrase != null) {
842                try {
843                    executeSimpleCommand(mIdPhrase);
844                } catch (ImapException ie) {
845                    // Log for debugging, but this is not a fatal problem.
846                    if (Email.DEBUG) {
847                        Log.d(Logging.LOG_TAG, ie.toString());
848                    }
849                } catch (IOException ioe) {
850                    // Special case to handle malformed OK responses and ignore them.
851                    // A true IOException will recur on the following login steps
852                    // This can go away after the parser is fixed - see bug 2138981
853                }
854            }
855        }
856
857        /**
858         * Gets the user's Personal Namespace from the IMAP server per RFC 2342. If the user
859         * explicitly sets a namespace (using setup UI) or if the server does not support the
860         * namespace command, this will perform no operation.
861         */
862        private void doGetNamespace(boolean hasNamespaceCapability) throws MessagingException {
863            // user did not specify a hard-coded prefix; try to get it from the server
864            if (hasNamespaceCapability && TextUtils.isEmpty(mPathPrefix)) {
865                List<ImapResponse> responseList = Collections.emptyList();
866
867                try {
868                    responseList = executeSimpleCommand(ImapConstants.NAMESPACE);
869                } catch (ImapException ie) {
870                    // Log for debugging, but this is not a fatal problem.
871                    if (Email.DEBUG) {
872                        Log.d(Logging.LOG_TAG, ie.toString());
873                    }
874                } catch (IOException ioe) {
875                    // Special case to handle malformed OK responses and ignore them.
876                }
877
878                for (ImapResponse response: responseList) {
879                    if (response.isDataResponse(0, ImapConstants.NAMESPACE)) {
880                        ImapList namespaceList = response.getListOrEmpty(1);
881                        ImapList namespace = namespaceList.getListOrEmpty(0);
882                        String namespaceString = namespace.getStringOrEmpty(0).getString();
883                        if (!TextUtils.isEmpty(namespaceString)) {
884                            mPathPrefix = decodeFolderName(namespaceString, null);
885                            mPathSeparator = namespace.getStringOrEmpty(1).getString();
886                        }
887                    }
888                }
889            }
890        }
891
892        /**
893         * Logs into the IMAP server
894         */
895        private void doLogin()
896                throws IOException, MessagingException, AuthenticationFailedException {
897            try {
898                // TODO eventually we need to add additional authentication
899                // options such as SASL
900                executeSimpleCommand(mLoginPhrase, true);
901            } catch (ImapException ie) {
902                if (Email.DEBUG) {
903                    Log.d(Logging.LOG_TAG, ie.toString());
904                }
905                throw new AuthenticationFailedException(ie.getAlertText(), ie);
906
907            } catch (MessagingException me) {
908                throw new AuthenticationFailedException(null, me);
909            }
910        }
911
912        /**
913         * Gets the path separator per the LIST command in RFC 3501. If the path separator
914         * was obtained while obtaining the namespace or there is no prefix defined, this
915         * will perform no operation.
916         */
917        private void doGetPathSeparator() throws MessagingException {
918            // user did not specify a hard-coded prefix; try to get it from the server
919            if (TextUtils.isEmpty(mPathSeparator) && !TextUtils.isEmpty(mPathPrefix)) {
920                List<ImapResponse> responseList = Collections.emptyList();
921
922                try {
923                    responseList = executeSimpleCommand(ImapConstants.LIST + " \"\" \"\"");
924                } catch (ImapException ie) {
925                    // Log for debugging, but this is not a fatal problem.
926                    if (Email.DEBUG) {
927                        Log.d(Logging.LOG_TAG, ie.toString());
928                    }
929                } catch (IOException ioe) {
930                    // Special case to handle malformed OK responses and ignore them.
931                }
932
933                for (ImapResponse response: responseList) {
934                    if (response.isDataResponse(0, ImapConstants.LIST)) {
935                        mPathSeparator = response.getStringOrEmpty(2).getString();
936                    }
937                }
938            }
939        }
940
941        /**
942         * Starts a TLS session with the IMAP server per RFC 3501. If the user has not opted
943         * to use TLS or the server does not support the TLS capability, this will perform
944         * no operation.
945         */
946        private ImapResponse doStartTls(boolean hasStartTlsCapability)
947                throws IOException, MessagingException {
948            if (mTransport.canTryTlsSecurity()) {
949                if (hasStartTlsCapability) {
950                    // STARTTLS
951                    executeSimpleCommand(ImapConstants.STARTTLS);
952
953                    mTransport.reopenTls();
954                    mTransport.setSoTimeout(MailTransport.SOCKET_READ_TIMEOUT);
955                    createParser();
956                    // Per RFC requirement (3501-6.2.1) gather new capabilities
957                    return(queryCapabilities());
958                } else {
959                    if (Email.DEBUG) {
960                        Log.d(Logging.LOG_TAG, "TLS not supported but required");
961                    }
962                    throw new MessagingException(MessagingException.TLS_REQUIRED);
963                }
964            }
965            return null;
966        }
967
968        /** @see DiscourseLogger#logLastDiscourse() */
969        public void logLastDiscourse() {
970            mDiscourse.logLastDiscourse();
971        }
972    }
973
974    static class ImapMessage extends MimeMessage {
975        ImapMessage(String uid, ImapFolder folder) {
976            this.mUid = uid;
977            this.mFolder = folder;
978        }
979
980        public void setSize(int size) {
981            this.mSize = size;
982        }
983
984        @Override
985        public void parse(InputStream in) throws IOException, MessagingException {
986            super.parse(in);
987        }
988
989        public void setFlagInternal(Flag flag, boolean set) throws MessagingException {
990            super.setFlag(flag, set);
991        }
992
993        @Override
994        public void setFlag(Flag flag, boolean set) throws MessagingException {
995            super.setFlag(flag, set);
996            mFolder.setFlags(new Message[] { this }, new Flag[] { flag }, set);
997        }
998    }
999
1000    static class ImapException extends MessagingException {
1001        private static final long serialVersionUID = 1L;
1002
1003        String mAlertText;
1004
1005        public ImapException(String message, String alertText, Throwable throwable) {
1006            super(message, throwable);
1007            this.mAlertText = alertText;
1008        }
1009
1010        public ImapException(String message, String alertText) {
1011            super(message);
1012            this.mAlertText = alertText;
1013        }
1014
1015        public String getAlertText() {
1016            return mAlertText;
1017        }
1018
1019        public void setAlertText(String alertText) {
1020            mAlertText = alertText;
1021        }
1022    }
1023}
1024