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 android.content.Context;
20import android.os.Build;
21import android.os.Bundle;
22import android.telephony.TelephonyManager;
23import android.text.TextUtils;
24import android.util.Base64;
25
26import com.android.email.LegacyConversions;
27import com.android.email.Preferences;
28import com.android.email.mail.Store;
29import com.android.email.mail.store.imap.ImapConstants;
30import com.android.email.mail.store.imap.ImapResponse;
31import com.android.email.mail.store.imap.ImapString;
32import com.android.email.mail.transport.MailTransport;
33import com.android.emailcommon.Logging;
34import com.android.emailcommon.VendorPolicyLoader;
35import com.android.emailcommon.internet.MimeMessage;
36import com.android.emailcommon.mail.AuthenticationFailedException;
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.Account;
42import com.android.emailcommon.provider.Credential;
43import com.android.emailcommon.provider.HostAuth;
44import com.android.emailcommon.provider.Mailbox;
45import com.android.emailcommon.service.EmailServiceProxy;
46import com.android.emailcommon.utility.Utility;
47import com.android.mail.utils.LogUtils;
48import com.beetstra.jutf7.CharsetProvider;
49import com.google.common.annotations.VisibleForTesting;
50
51import java.io.IOException;
52import java.io.InputStream;
53import java.nio.ByteBuffer;
54import java.nio.charset.Charset;
55import java.security.MessageDigest;
56import java.security.NoSuchAlgorithmException;
57import java.util.Collection;
58import java.util.HashMap;
59import java.util.List;
60import java.util.Set;
61import java.util.concurrent.ConcurrentLinkedQueue;
62import java.util.regex.Pattern;
63
64
65/**
66 * <pre>
67 * TODO Need to start keeping track of UIDVALIDITY
68 * TODO Need a default response handler for things like folder updates
69 * TODO In fetch(), if we need a ImapMessage and were given
70 *      something else we can try to do a pre-fetch first.
71 * TODO Collect ALERT messages and show them to users.
72 *
73 * ftp://ftp.isi.edu/in-notes/rfc2683.txt When a client asks for
74 * certain information in a FETCH command, the server may return the requested
75 * information in any order, not necessarily in the order that it was requested.
76 * Further, the server may return the information in separate FETCH responses
77 * and may also return information that was not explicitly requested (to reflect
78 * to the client changes in the state of the subject message).
79 * </pre>
80 */
81public class ImapStore extends Store {
82    /** Charset used for converting folder names to and from UTF-7 as defined by RFC 3501. */
83    private static final Charset MODIFIED_UTF_7_CHARSET =
84            new CharsetProvider().charsetForName("X-RFC-3501");
85
86    @VisibleForTesting static String sImapId = null;
87    @VisibleForTesting String mPathPrefix;
88    @VisibleForTesting String mPathSeparator;
89
90    private boolean mUseOAuth;
91
92    private final ConcurrentLinkedQueue<ImapConnection> mConnectionPool =
93            new ConcurrentLinkedQueue<ImapConnection>();
94
95    /**
96     * Static named constructor.
97     */
98    public static Store newInstance(Account account, Context context) throws MessagingException {
99        return new ImapStore(context, account);
100    }
101
102    /**
103     * Creates a new store for the given account. Always use
104     * {@link #newInstance(Account, Context)} to create an IMAP store.
105     */
106    private ImapStore(Context context, Account account) throws MessagingException {
107        mContext = context;
108        mAccount = account;
109
110        HostAuth recvAuth = account.getOrCreateHostAuthRecv(context);
111        if (recvAuth == null) {
112            throw new MessagingException("No HostAuth in ImapStore?");
113        }
114        mTransport = new MailTransport(context, "IMAP", recvAuth);
115
116        String[] userInfo = recvAuth.getLogin();
117        mUsername = userInfo[0];
118        mPassword = userInfo[1];
119        final Credential cred = recvAuth.getCredential(context);
120        mUseOAuth = (cred != null);
121        mPathPrefix = recvAuth.mDomain;
122    }
123
124    boolean getUseOAuth() {
125        return mUseOAuth;
126    }
127
128    String getUsername() {
129        return mUsername;
130    }
131
132    String getPassword() {
133        return mPassword;
134    }
135
136    @VisibleForTesting
137    Collection<ImapConnection> getConnectionPoolForTest() {
138        return mConnectionPool;
139    }
140
141    /**
142     * For testing only.  Injects a different root transport (it will be copied using
143     * newInstanceWithConfiguration() each time IMAP sets up a new channel).  The transport
144     * should already be set up and ready to use.  Do not use for real code.
145     * @param testTransport The Transport to inject and use for all future communication.
146     */
147    @VisibleForTesting
148    void setTransportForTest(MailTransport testTransport) {
149        mTransport = testTransport;
150    }
151
152    /**
153     * Return, or create and return, an string suitable for use in an IMAP ID message.
154     * This is constructed similarly to the way the browser sets up its user-agent strings.
155     * See RFC 2971 for more details.  The output of this command will be a series of key-value
156     * pairs delimited by spaces (there is no point in returning a structured result because
157     * this will be sent as-is to the IMAP server).  No tokens, parenthesis or "ID" are included,
158     * because some connections may append additional values.
159     *
160     * The following IMAP ID keys may be included:
161     *   name                   Android package name of the program
162     *   os                     "android"
163     *   os-version             "version; model; build-id"
164     *   vendor                 Vendor of the client/server
165     *   x-android-device-model Model (only revealed if release build)
166     *   x-android-net-operator Mobile network operator (if known)
167     *   AGUID                  A device+account UID
168     *
169     * In addition, a vendor policy .apk can append key/value pairs.
170     *
171     * @param userName the username of the account
172     * @param host the host (server) of the account
173     * @param capabilities a list of the capabilities from the server
174     * @return a String for use in an IMAP ID message.
175     */
176    public static String getImapId(Context context, String userName, String host,
177            String capabilities) {
178        // The first section is global to all IMAP connections, and generates the fixed
179        // values in any IMAP ID message
180        synchronized (ImapStore.class) {
181            if (sImapId == null) {
182                TelephonyManager tm =
183                        (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
184                String networkOperator = tm.getNetworkOperatorName();
185                if (networkOperator == null) networkOperator = "";
186
187                sImapId = makeCommonImapId(context.getPackageName(), Build.VERSION.RELEASE,
188                        Build.VERSION.CODENAME, Build.MODEL, Build.ID, Build.MANUFACTURER,
189                        networkOperator);
190            }
191        }
192
193        // This section is per Store, and adds in a dynamic elements like UID's.
194        // We don't cache the result of this work, because the caller does anyway.
195        StringBuilder id = new StringBuilder(sImapId);
196
197        // Optionally add any vendor-supplied id keys
198        String vendorId = VendorPolicyLoader.getInstance(context).getImapIdValues(userName, host,
199                capabilities);
200        if (vendorId != null) {
201            id.append(' ');
202            id.append(vendorId);
203        }
204
205        // Generate a UID that mixes a "stable" device UID with the email address
206        try {
207            String devUID = Preferences.getPreferences(context).getDeviceUID();
208            MessageDigest messageDigest;
209            messageDigest = MessageDigest.getInstance("SHA-1");
210            messageDigest.update(userName.getBytes());
211            messageDigest.update(devUID.getBytes());
212            byte[] uid = messageDigest.digest();
213            String hexUid = Base64.encodeToString(uid, Base64.NO_WRAP);
214            id.append(" \"AGUID\" \"");
215            id.append(hexUid);
216            id.append('\"');
217        } catch (NoSuchAlgorithmException e) {
218            LogUtils.d(Logging.LOG_TAG, "couldn't obtain SHA-1 hash for device UID");
219        }
220        return id.toString();
221    }
222
223    /**
224     * Helper function that actually builds the static part of the IMAP ID string.  This is
225     * separated from getImapId for testability.  There is no escaping or encoding in IMAP ID so
226     * any rogue chars must be filtered here.
227     *
228     * @param packageName context.getPackageName()
229     * @param version Build.VERSION.RELEASE
230     * @param codeName Build.VERSION.CODENAME
231     * @param model Build.MODEL
232     * @param id Build.ID
233     * @param vendor Build.MANUFACTURER
234     * @param networkOperator TelephonyManager.getNetworkOperatorName()
235     * @return the static (never changes) portion of the IMAP ID
236     */
237    @VisibleForTesting
238    static String makeCommonImapId(String packageName, String version,
239            String codeName, String model, String id, String vendor, String networkOperator) {
240
241        // Before building up IMAP ID string, pre-filter the input strings for "legal" chars
242        // This is using a fairly arbitrary char set intended to pass through most reasonable
243        // version, model, and vendor strings: a-z A-Z 0-9 - _ + = ; : . , / <space>
244        // The most important thing is *not* to pass parens, quotes, or CRLF, which would break
245        // the format of the IMAP ID list.
246        Pattern p = Pattern.compile("[^a-zA-Z0-9-_\\+=;:\\.,/ ]");
247        packageName = p.matcher(packageName).replaceAll("");
248        version = p.matcher(version).replaceAll("");
249        codeName = p.matcher(codeName).replaceAll("");
250        model = p.matcher(model).replaceAll("");
251        id = p.matcher(id).replaceAll("");
252        vendor = p.matcher(vendor).replaceAll("");
253        networkOperator = p.matcher(networkOperator).replaceAll("");
254
255        // "name" "com.android.email"
256        StringBuilder sb = new StringBuilder("\"name\" \"");
257        sb.append(packageName);
258        sb.append("\"");
259
260        // "os" "android"
261        sb.append(" \"os\" \"android\"");
262
263        // "os-version" "version; build-id"
264        sb.append(" \"os-version\" \"");
265        if (version.length() > 0) {
266            sb.append(version);
267        } else {
268            // default to "1.0"
269            sb.append("1.0");
270        }
271        // add the build ID or build #
272        if (id.length() > 0) {
273            sb.append("; ");
274            sb.append(id);
275        }
276        sb.append("\"");
277
278        // "vendor" "the vendor"
279        if (vendor.length() > 0) {
280            sb.append(" \"vendor\" \"");
281            sb.append(vendor);
282            sb.append("\"");
283        }
284
285        // "x-android-device-model" the device model (on release builds only)
286        if ("REL".equals(codeName)) {
287            if (model.length() > 0) {
288                sb.append(" \"x-android-device-model\" \"");
289                sb.append(model);
290                sb.append("\"");
291            }
292        }
293
294        // "x-android-mobile-net-operator" "name of network operator"
295        if (networkOperator.length() > 0) {
296            sb.append(" \"x-android-mobile-net-operator\" \"");
297            sb.append(networkOperator);
298            sb.append("\"");
299        }
300
301        return sb.toString();
302    }
303
304
305    @Override
306    public Folder getFolder(String name) {
307        return new ImapFolder(this, name);
308    }
309
310    /**
311     * Creates a mailbox hierarchy out of the flat data provided by the server.
312     */
313    @VisibleForTesting
314    static void createHierarchy(HashMap<String, ImapFolder> mailboxes) {
315        Set<String> pathnames = mailboxes.keySet();
316        for (String path : pathnames) {
317            final ImapFolder folder = mailboxes.get(path);
318            final Mailbox mailbox = folder.mMailbox;
319            int delimiterIdx = mailbox.mServerId.lastIndexOf(mailbox.mDelimiter);
320            long parentKey = Mailbox.NO_MAILBOX;
321            String parentPath = null;
322            if (delimiterIdx != -1) {
323                parentPath = path.substring(0, delimiterIdx);
324                final ImapFolder parentFolder = mailboxes.get(parentPath);
325                final Mailbox parentMailbox = (parentFolder == null) ? null : parentFolder.mMailbox;
326                if (parentMailbox != null) {
327                    parentKey = parentMailbox.mId;
328                    parentMailbox.mFlags
329                            |= (Mailbox.FLAG_HAS_CHILDREN | Mailbox.FLAG_CHILDREN_VISIBLE);
330                }
331            }
332            mailbox.mParentKey = parentKey;
333            mailbox.mParentServerId = parentPath;
334        }
335    }
336
337    /**
338     * Creates a {@link Folder} and associated {@link Mailbox}. If the folder does not already
339     * exist in the local database, a new row will immediately be created in the mailbox table.
340     * Otherwise, the existing row will be used. Any changes to existing rows, will not be stored
341     * to the database immediately.
342     * @param accountId The ID of the account the mailbox is to be associated with
343     * @param mailboxPath The path of the mailbox to add
344     * @param delimiter A path delimiter. May be {@code null} if there is no delimiter.
345     * @param selectable If {@code true}, the mailbox can be selected and used to store messages.
346     * @param mailbox If not null, mailbox is used instead of querying for the Mailbox.
347     */
348    private ImapFolder addMailbox(Context context, long accountId, String mailboxPath,
349            char delimiter, boolean selectable, Mailbox mailbox) {
350        // TODO: pass in the mailbox type, or do a proper lookup here
351        final int mailboxType;
352        if (mailbox == null) {
353            mailboxType = LegacyConversions.inferMailboxTypeFromName(context, mailboxPath);
354            mailbox = Mailbox.getMailboxForPath(context, accountId, mailboxPath);
355        } else {
356            mailboxType = mailbox.mType;
357        }
358        final ImapFolder folder = (ImapFolder) getFolder(mailboxPath);
359        if (mailbox.isSaved()) {
360            // existing mailbox
361            // mailbox retrieved from database; save hash _before_ updating fields
362            folder.mHash = mailbox.getHashes();
363        }
364        updateMailbox(mailbox, accountId, mailboxPath, delimiter, selectable, mailboxType);
365        if (folder.mHash == null) {
366            // new mailbox
367            // save hash after updating. allows tracking changes if the mailbox is saved
368            // outside of #saveMailboxList()
369            folder.mHash = mailbox.getHashes();
370            // We must save this here to make sure we have a valid ID for later
371            mailbox.save(mContext);
372        }
373        folder.mMailbox = mailbox;
374        return folder;
375    }
376
377    /**
378     * Persists the folders in the given list.
379     */
380    private static void saveMailboxList(Context context, HashMap<String, ImapFolder> folderMap) {
381        for (ImapFolder imapFolder : folderMap.values()) {
382            imapFolder.save(context);
383        }
384    }
385
386    @Override
387    public Folder[] updateFolders() throws MessagingException {
388        // TODO: There is nothing that ever closes this connection. Trouble is, it's not exactly
389        // clear when we should close it, we'd like to keep it open until we're really done
390        // using it.
391        ImapConnection connection = getConnection();
392        try {
393            final HashMap<String, ImapFolder> mailboxes = new HashMap<String, ImapFolder>();
394            // Establish a connection to the IMAP server; if necessary
395            // This ensures a valid prefix if the prefix is automatically set by the server
396            connection.executeSimpleCommand(ImapConstants.NOOP);
397            String imapCommand = ImapConstants.LIST + " \"\" \"*\"";
398            if (mPathPrefix != null) {
399                imapCommand = ImapConstants.LIST + " \"\" \"" + mPathPrefix + "*\"";
400            }
401            List<ImapResponse> responses = connection.executeSimpleCommand(imapCommand);
402            for (ImapResponse response : responses) {
403                // S: * LIST (\Noselect) "/" ~/Mail/foo
404                if (response.isDataResponse(0, ImapConstants.LIST)) {
405                    // Get folder name.
406                    ImapString encodedFolder = response.getStringOrEmpty(3);
407                    if (encodedFolder.isEmpty()) continue;
408
409                    String folderName = decodeFolderName(encodedFolder.getString(), mPathPrefix);
410
411                    if (ImapConstants.INBOX.equalsIgnoreCase(folderName)) continue;
412
413                    // Parse attributes.
414                    boolean selectable =
415                        !response.getListOrEmpty(1).contains(ImapConstants.FLAG_NO_SELECT);
416                    String delimiter = response.getStringOrEmpty(2).getString();
417                    char delimiterChar = '\0';
418                    if (!TextUtils.isEmpty(delimiter)) {
419                        delimiterChar = delimiter.charAt(0);
420                    }
421                    ImapFolder folder = addMailbox(
422                            mContext, mAccount.mId, folderName, delimiterChar, selectable, null);
423                    mailboxes.put(folderName, folder);
424                }
425            }
426
427            // In order to properly map INBOX -> Inbox, handle it as a special case.
428            final Mailbox inbox =
429                    Mailbox.restoreMailboxOfType(mContext, mAccount.mId, Mailbox.TYPE_INBOX);
430            final ImapFolder newFolder = addMailbox(
431                    mContext, mAccount.mId, inbox.mServerId, '\0', true /*selectable*/, inbox);
432            mailboxes.put(ImapConstants.INBOX, newFolder);
433
434            createHierarchy(mailboxes);
435            saveMailboxList(mContext, mailboxes);
436            return mailboxes.values().toArray(new Folder[mailboxes.size()]);
437        } catch (IOException ioe) {
438            connection.close();
439            throw new MessagingException("Unable to get folder list", ioe);
440        } catch (AuthenticationFailedException afe) {
441            // We do NOT want this connection pooled, or we will continue to send NOOP and SELECT
442            // commands to the server
443            connection.destroyResponses();
444            connection = null;
445            throw afe;
446        } finally {
447            if (connection != null) {
448                // We keep our connection out of the pool as long as we are using it, then
449                // put it back into the pool so it can be reused.
450                poolConnection(connection);
451            }
452        }
453    }
454
455    @Override
456    public Bundle checkSettings() throws MessagingException {
457        int result = MessagingException.NO_ERROR;
458        Bundle bundle = new Bundle();
459        // TODO: why doesn't this use getConnection()? I guess this is only done during setup,
460        // so there's need to look for a pooled connection?
461        // But then why doesn't it use poolConnection() after it's done?
462        ImapConnection connection = new ImapConnection(this);
463        try {
464            connection.open();
465            connection.close();
466        } catch (IOException ioe) {
467            bundle.putString(EmailServiceProxy.VALIDATE_BUNDLE_ERROR_MESSAGE, ioe.getMessage());
468            result = MessagingException.IOERROR;
469        } finally {
470            connection.destroyResponses();
471        }
472        bundle.putInt(EmailServiceProxy.VALIDATE_BUNDLE_RESULT_CODE, result);
473        return bundle;
474    }
475
476    /**
477     * Returns whether or not the prefix has been set by the user. This can be determined by
478     * the fact that the prefix is set, but, the path separator is not set.
479     */
480    boolean isUserPrefixSet() {
481        return TextUtils.isEmpty(mPathSeparator) && !TextUtils.isEmpty(mPathPrefix);
482    }
483
484    /** Sets the path separator */
485    void setPathSeparator(String pathSeparator) {
486        mPathSeparator = pathSeparator;
487    }
488
489    /** Sets the prefix */
490    void setPathPrefix(String pathPrefix) {
491        mPathPrefix = pathPrefix;
492    }
493
494    /** Gets the context for this store */
495    Context getContext() {
496        return mContext;
497    }
498
499    /** Returns a clone of the transport associated with this store. */
500    MailTransport cloneTransport() {
501        return mTransport.clone();
502    }
503
504    /**
505     * Fixes the path prefix, if necessary. The path prefix must always end with the
506     * path separator.
507     */
508    void ensurePrefixIsValid() {
509        // Make sure the path prefix ends with the path separator
510        if (!TextUtils.isEmpty(mPathPrefix) && !TextUtils.isEmpty(mPathSeparator)) {
511            if (!mPathPrefix.endsWith(mPathSeparator)) {
512                mPathPrefix = mPathPrefix + mPathSeparator;
513            }
514        }
515    }
516
517    /**
518     * Gets a connection if one is available from the pool, or creates a new one if not.
519     */
520    ImapConnection getConnection() {
521        // TODO Why would we ever have (or need to have) more than one active connection?
522        // TODO We set new username/password each time, but we don't actually close the transport
523        // when we do this. So if that information has changed, this connection will fail.
524        ImapConnection connection;
525        while ((connection = mConnectionPool.poll()) != null) {
526            try {
527                connection.setStore(this);
528                connection.executeSimpleCommand(ImapConstants.NOOP);
529                break;
530            } catch (MessagingException e) {
531                // Fall through
532            } catch (IOException e) {
533                // Fall through
534            }
535            connection.close();
536        }
537
538        if (connection == null) {
539            connection = new ImapConnection(this);
540        }
541        return connection;
542    }
543
544    /**
545     * Save a {@link ImapConnection} in the pool for reuse. Any responses associated with the
546     * connection are destroyed before adding the connection to the pool.
547     */
548    void poolConnection(ImapConnection connection) {
549        if (connection != null) {
550            connection.destroyResponses();
551            mConnectionPool.add(connection);
552        }
553    }
554
555    /**
556     * Prepends the folder name with the given prefix and UTF-7 encodes it.
557     */
558    static String encodeFolderName(String name, String prefix) {
559        // do NOT add the prefix to the special name "INBOX"
560        if (ImapConstants.INBOX.equalsIgnoreCase(name)) return name;
561
562        // Prepend prefix
563        if (prefix != null) {
564            name = prefix + name;
565        }
566
567        // TODO bypass the conversion if name doesn't have special char.
568        ByteBuffer bb = MODIFIED_UTF_7_CHARSET.encode(name);
569        byte[] b = new byte[bb.limit()];
570        bb.get(b);
571
572        return Utility.fromAscii(b);
573    }
574
575    /**
576     * UTF-7 decodes the folder name and removes the given path prefix.
577     */
578    static String decodeFolderName(String name, String prefix) {
579        // TODO bypass the conversion if name doesn't have special char.
580        String folder;
581        folder = MODIFIED_UTF_7_CHARSET.decode(ByteBuffer.wrap(Utility.toAscii(name))).toString();
582        if ((prefix != null) && folder.startsWith(prefix)) {
583            folder = folder.substring(prefix.length());
584        }
585        return folder;
586    }
587
588    /**
589     * Returns UIDs of Messages joined with "," as the separator.
590     */
591    static String joinMessageUids(Message[] messages) {
592        StringBuilder sb = new StringBuilder();
593        boolean notFirst = false;
594        for (Message m : messages) {
595            if (notFirst) {
596                sb.append(',');
597            }
598            sb.append(m.getUid());
599            notFirst = true;
600        }
601        return sb.toString();
602    }
603
604    static class ImapMessage extends MimeMessage {
605        ImapMessage(String uid, ImapFolder folder) {
606            mUid = uid;
607            mFolder = folder;
608        }
609
610        public void setSize(int size) {
611            mSize = size;
612        }
613
614        @Override
615        public void parse(InputStream in) throws IOException, MessagingException {
616            super.parse(in);
617        }
618
619        public void setFlagInternal(Flag flag, boolean set) throws MessagingException {
620            super.setFlag(flag, set);
621        }
622
623        @Override
624        public void setFlag(Flag flag, boolean set) throws MessagingException {
625            super.setFlag(flag, set);
626            mFolder.setFlags(new Message[] { this }, new Flag[] { flag }, set);
627        }
628    }
629
630    static class ImapException extends MessagingException {
631        private static final long serialVersionUID = 1L;
632
633        private final String mAlertText;
634        private final String mResponseCode;
635
636        public ImapException(String message, String alertText, String responseCode) {
637            super(message);
638            mAlertText = alertText;
639            mResponseCode = responseCode;
640        }
641
642        public String getAlertText() {
643            return mAlertText;
644        }
645
646        public String getResponseCode() {
647            return mResponseCode;
648        }
649    }
650
651    public void closeConnections() {
652        ImapConnection connection;
653        while ((connection = mConnectionPool.poll()) != null) {
654            connection.close();
655        }
656    }
657}
658