1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.email.mail.store;
18
19import android.content.Context;
20import android.text.TextUtils;
21import android.text.format.DateUtils;
22import android.util.Base64DataException;
23
24import com.android.email.mail.store.ImapStore.ImapException;
25import com.android.email.mail.store.ImapStore.ImapMessage;
26import com.android.email.mail.store.imap.ImapConstants;
27import com.android.email.mail.store.imap.ImapElement;
28import com.android.email.mail.store.imap.ImapList;
29import com.android.email.mail.store.imap.ImapResponse;
30import com.android.email.mail.store.imap.ImapString;
31import com.android.email.mail.store.imap.ImapUtility;
32import com.android.email2.ui.MailActivityEmail;
33import com.android.emailcommon.Logging;
34import com.android.emailcommon.internet.BinaryTempFileBody;
35import com.android.emailcommon.internet.MimeBodyPart;
36import com.android.emailcommon.internet.MimeHeader;
37import com.android.emailcommon.internet.MimeMultipart;
38import com.android.emailcommon.internet.MimeUtility;
39import com.android.emailcommon.mail.AuthenticationFailedException;
40import com.android.emailcommon.mail.Body;
41import com.android.emailcommon.mail.FetchProfile;
42import com.android.emailcommon.mail.Flag;
43import com.android.emailcommon.mail.Folder;
44import com.android.emailcommon.mail.Message;
45import com.android.emailcommon.mail.MessagingException;
46import com.android.emailcommon.mail.Part;
47import com.android.emailcommon.provider.Mailbox;
48import com.android.emailcommon.service.SearchParams;
49import com.android.emailcommon.utility.CountingOutputStream;
50import com.android.emailcommon.utility.EOLConvertingOutputStream;
51import com.android.emailcommon.utility.Utility;
52import com.android.mail.utils.LogUtils;
53import com.google.common.annotations.VisibleForTesting;
54
55import java.io.IOException;
56import java.io.InputStream;
57import java.io.OutputStream;
58import java.text.SimpleDateFormat;
59import java.util.ArrayList;
60import java.util.Arrays;
61import java.util.Date;
62import java.util.Locale;
63import java.util.HashMap;
64import java.util.LinkedHashSet;
65import java.util.List;
66import java.util.TimeZone;
67
68class ImapFolder extends Folder {
69    private final static Flag[] PERMANENT_FLAGS =
70        { Flag.DELETED, Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED };
71    private static final int COPY_BUFFER_SIZE = 16*1024;
72
73    private final ImapStore mStore;
74    private final String mName;
75    private int mMessageCount = -1;
76    private ImapConnection mConnection;
77    private OpenMode mMode;
78    private boolean mExists;
79    /** The local mailbox associated with this remote folder */
80    Mailbox mMailbox;
81    /** A set of hashes that can be used to track dirtiness */
82    Object mHash[];
83
84    /*package*/ ImapFolder(ImapStore store, String name) {
85        mStore = store;
86        mName = name;
87    }
88
89    private void destroyResponses() {
90        if (mConnection != null) {
91            mConnection.destroyResponses();
92        }
93    }
94
95    @Override
96    public void open(OpenMode mode)
97            throws MessagingException {
98        try {
99            if (isOpen()) {
100                if (mMode == mode) {
101                    // Make sure the connection is valid.
102                    // If it's not we'll close it down and continue on to get a new one.
103                    try {
104                        mConnection.executeSimpleCommand(ImapConstants.NOOP);
105                        return;
106
107                    } catch (IOException ioe) {
108                        ioExceptionHandler(mConnection, ioe);
109                    } finally {
110                        destroyResponses();
111                    }
112                } else {
113                    // Return the connection to the pool, if exists.
114                    close(false);
115                }
116            }
117            synchronized (this) {
118                mConnection = mStore.getConnection();
119            }
120            // * FLAGS (\Answered \Flagged \Deleted \Seen \Draft NonJunk
121            // $MDNSent)
122            // * OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft
123            // NonJunk $MDNSent \*)] Flags permitted.
124            // * 23 EXISTS
125            // * 0 RECENT
126            // * OK [UIDVALIDITY 1125022061] UIDs valid
127            // * OK [UIDNEXT 57576] Predicted next UID
128            // 2 OK [READ-WRITE] Select completed.
129            try {
130                doSelect();
131            } catch (IOException ioe) {
132                throw ioExceptionHandler(mConnection, ioe);
133            } finally {
134                destroyResponses();
135            }
136        } catch (AuthenticationFailedException e) {
137            // Don't cache this connection, so we're forced to try connecting/login again
138            mConnection = null;
139            close(false);
140            throw e;
141        } catch (MessagingException e) {
142            mExists = false;
143            close(false);
144            throw e;
145        }
146    }
147
148    @Override
149    @VisibleForTesting
150    public boolean isOpen() {
151        return mExists && mConnection != null;
152    }
153
154    @Override
155    public OpenMode getMode() {
156        return mMode;
157    }
158
159    @Override
160    public void close(boolean expunge) {
161        // TODO implement expunge
162        mMessageCount = -1;
163        synchronized (this) {
164            mStore.poolConnection(mConnection);
165            mConnection = null;
166        }
167    }
168
169    @Override
170    public String getName() {
171        return mName;
172    }
173
174    @Override
175    public boolean exists() throws MessagingException {
176        if (mExists) {
177            return true;
178        }
179        /*
180         * This method needs to operate in the unselected mode as well as the selected mode
181         * so we must get the connection ourselves if it's not there. We are specifically
182         * not calling checkOpen() since we don't care if the folder is open.
183         */
184        ImapConnection connection = null;
185        synchronized(this) {
186            if (mConnection == null) {
187                connection = mStore.getConnection();
188            } else {
189                connection = mConnection;
190            }
191        }
192        try {
193            connection.executeSimpleCommand(String.format(Locale.US,
194                    ImapConstants.STATUS + " \"%s\" (" + ImapConstants.UIDVALIDITY + ")",
195                    ImapStore.encodeFolderName(mName, mStore.mPathPrefix)));
196            mExists = true;
197            return true;
198
199        } catch (MessagingException me) {
200            // Treat IOERROR messaging exception as IOException
201            if (me.getExceptionType() == MessagingException.IOERROR) {
202                throw me;
203            }
204            return false;
205
206        } catch (IOException ioe) {
207            throw ioExceptionHandler(connection, ioe);
208
209        } finally {
210            connection.destroyResponses();
211            if (mConnection == null) {
212                mStore.poolConnection(connection);
213            }
214        }
215    }
216
217    // IMAP supports folder creation
218    @Override
219    public boolean canCreate(FolderType type) {
220        return true;
221    }
222
223    @Override
224    public boolean create(FolderType type) throws MessagingException {
225        /*
226         * This method needs to operate in the unselected mode as well as the selected mode
227         * so we must get the connection ourselves if it's not there. We are specifically
228         * not calling checkOpen() since we don't care if the folder is open.
229         */
230        ImapConnection connection = null;
231        synchronized(this) {
232            if (mConnection == null) {
233                connection = mStore.getConnection();
234            } else {
235                connection = mConnection;
236            }
237        }
238        try {
239            connection.executeSimpleCommand(String.format(Locale.US,
240                    ImapConstants.CREATE + " \"%s\"",
241                    ImapStore.encodeFolderName(mName, mStore.mPathPrefix)));
242            return true;
243
244        } catch (MessagingException me) {
245            return false;
246
247        } catch (IOException ioe) {
248            throw ioExceptionHandler(connection, ioe);
249
250        } finally {
251            connection.destroyResponses();
252            if (mConnection == null) {
253                mStore.poolConnection(connection);
254            }
255        }
256    }
257
258    @Override
259    public void copyMessages(Message[] messages, Folder folder,
260            MessageUpdateCallbacks callbacks) throws MessagingException {
261        checkOpen();
262        try {
263            List<ImapResponse> responseList = mConnection.executeSimpleCommand(
264                    String.format(Locale.US, ImapConstants.UID_COPY + " %s \"%s\"",
265                            ImapStore.joinMessageUids(messages),
266                            ImapStore.encodeFolderName(folder.getName(), mStore.mPathPrefix)));
267            // Build a message map for faster UID matching
268            HashMap<String, Message> messageMap = new HashMap<String, Message>();
269            boolean handledUidPlus = false;
270            for (Message m : messages) {
271                messageMap.put(m.getUid(), m);
272            }
273            // Process response to get the new UIDs
274            for (ImapResponse response : responseList) {
275                // All "BAD" responses are bad. Only "NO", tagged responses are bad.
276                if (response.isBad() || (response.isNo() && response.isTagged())) {
277                    String responseText = response.getStatusResponseTextOrEmpty().getString();
278                    throw new MessagingException(responseText);
279                }
280                // Skip untagged responses; they're just status
281                if (!response.isTagged()) {
282                    continue;
283                }
284                // No callback provided to report of UID changes; nothing more to do here
285                // NOTE: We check this here to catch any server errors
286                if (callbacks == null) {
287                    continue;
288                }
289                ImapList copyResponse = response.getListOrEmpty(1);
290                String responseCode = copyResponse.getStringOrEmpty(0).getString();
291                if (ImapConstants.COPYUID.equals(responseCode)) {
292                    handledUidPlus = true;
293                    String origIdSet = copyResponse.getStringOrEmpty(2).getString();
294                    String newIdSet = copyResponse.getStringOrEmpty(3).getString();
295                    String[] origIdArray = ImapUtility.getImapSequenceValues(origIdSet);
296                    String[] newIdArray = ImapUtility.getImapSequenceValues(newIdSet);
297                    // There has to be a 1:1 mapping between old and new IDs
298                    if (origIdArray.length != newIdArray.length) {
299                        throw new MessagingException("Set length mis-match; orig IDs \"" +
300                                origIdSet + "\"  new IDs \"" + newIdSet + "\"");
301                    }
302                    for (int i = 0; i < origIdArray.length; i++) {
303                        final String id = origIdArray[i];
304                        final Message m = messageMap.get(id);
305                        if (m != null) {
306                            callbacks.onMessageUidChange(m, newIdArray[i]);
307                        }
308                    }
309                }
310            }
311            // If the server doesn't support UIDPLUS, try a different way to get the new UID(s)
312            if (callbacks != null && !handledUidPlus) {
313                final ImapFolder newFolder = (ImapFolder)folder;
314                try {
315                    // Temporarily select the destination folder
316                    newFolder.open(OpenMode.READ_WRITE);
317                    // Do the search(es) ...
318                    for (Message m : messages) {
319                        final String searchString =
320                                "HEADER Message-Id \"" + m.getMessageId() + "\"";
321                        final String[] newIdArray = newFolder.searchForUids(searchString);
322                        if (newIdArray.length == 1) {
323                            callbacks.onMessageUidChange(m, newIdArray[0]);
324                        }
325                    }
326                } catch (MessagingException e) {
327                    // Log, but, don't abort; failures here don't need to be propagated
328                    LogUtils.d(Logging.LOG_TAG, "Failed to find message", e);
329                } finally {
330                    newFolder.close(false);
331                }
332                // Re-select the original folder
333                doSelect();
334            }
335        } catch (IOException ioe) {
336            throw ioExceptionHandler(mConnection, ioe);
337        } finally {
338            destroyResponses();
339        }
340    }
341
342    @Override
343    public int getMessageCount() {
344        return mMessageCount;
345    }
346
347    @Override
348    public int getUnreadMessageCount() throws MessagingException {
349        checkOpen();
350        try {
351            int unreadMessageCount = 0;
352            final List<ImapResponse> responses = mConnection.executeSimpleCommand(
353                    String.format(Locale.US,
354                            ImapConstants.STATUS + " \"%s\" (" + ImapConstants.UNSEEN + ")",
355                            ImapStore.encodeFolderName(mName, mStore.mPathPrefix)));
356            // S: * STATUS mboxname (MESSAGES 231 UIDNEXT 44292)
357            for (ImapResponse response : responses) {
358                if (response.isDataResponse(0, ImapConstants.STATUS)) {
359                    unreadMessageCount = response.getListOrEmpty(2)
360                            .getKeyedStringOrEmpty(ImapConstants.UNSEEN).getNumberOrZero();
361                }
362            }
363            return unreadMessageCount;
364        } catch (IOException ioe) {
365            throw ioExceptionHandler(mConnection, ioe);
366        } finally {
367            destroyResponses();
368        }
369    }
370
371    @Override
372    public void delete(boolean recurse) {
373        throw new Error("ImapStore.delete() not yet implemented");
374    }
375
376    String[] getSearchUids(List<ImapResponse> responses) {
377        // S: * SEARCH 2 3 6
378        final ArrayList<String> uids = new ArrayList<String>();
379        for (ImapResponse response : responses) {
380            if (!response.isDataResponse(0, ImapConstants.SEARCH)) {
381                continue;
382            }
383            // Found SEARCH response data
384            for (int i = 1; i < response.size(); i++) {
385                ImapString s = response.getStringOrEmpty(i);
386                if (s.isString()) {
387                    uids.add(s.getString());
388                }
389            }
390        }
391        return uids.toArray(Utility.EMPTY_STRINGS);
392    }
393
394    String[] searchForUids(String searchCriteria) throws MessagingException {
395        return searchForUids(searchCriteria, true);
396    }
397
398    /**
399     * I'm not a fan of having a parameter that determines whether to throw exceptions or
400     * consume them, but getMessage() for a date range needs to differentiate between
401     * a failure and just a legitimate empty result.
402     * See b/11183568.
403     * TODO:
404     * Either figure out how to make getMessage() with a date range work without this
405     * exception information, or make all users of searchForUids() handle the ImapException.
406     * It's too late in the release cycle to add this risk right now.
407     */
408    @VisibleForTesting
409    String[] searchForUids(String searchCriteria, boolean swallowException)
410            throws MessagingException {
411        checkOpen();
412        try {
413            try {
414                final String command = ImapConstants.UID_SEARCH + " " + searchCriteria;
415                final String[] result = getSearchUids(mConnection.executeSimpleCommand(command));
416                LogUtils.d(Logging.LOG_TAG, "searchForUids '" + searchCriteria + "' results: " +
417                        result.length);
418                return result;
419            } catch (ImapException me) {
420                LogUtils.d(Logging.LOG_TAG, me, "ImapException in search: " + searchCriteria);
421                if (swallowException) {
422                    return Utility.EMPTY_STRINGS; // Not found
423                } else {
424                    throw me;
425                }
426            } catch (IOException ioe) {
427                LogUtils.d(Logging.LOG_TAG, ioe, "IOException in search: " + searchCriteria);
428                throw ioExceptionHandler(mConnection, ioe);
429            }
430        } finally {
431            destroyResponses();
432        }
433    }
434
435    @Override
436    @VisibleForTesting
437    public Message getMessage(String uid) throws MessagingException {
438        checkOpen();
439
440        final String[] uids = searchForUids(ImapConstants.UID + " " + uid);
441        for (int i = 0; i < uids.length; i++) {
442            if (uids[i].equals(uid)) {
443                return new ImapMessage(uid, this);
444            }
445        }
446        return null;
447    }
448
449    @VisibleForTesting
450    protected static boolean isAsciiString(String str) {
451        int len = str.length();
452        for (int i = 0; i < len; i++) {
453            char c = str.charAt(i);
454            if (c >= 128) return false;
455        }
456        return true;
457    }
458
459    /**
460     * Retrieve messages based on search parameters.  We search FROM, TO, CC, SUBJECT, and BODY
461     * We send: SEARCH OR FROM "foo" (OR TO "foo" (OR CC "foo" (OR SUBJECT "foo" BODY "foo"))), but
462     * with the additional CHARSET argument and sending "foo" as a literal (e.g. {3}<CRLF>foo}
463     */
464    @Override
465    @VisibleForTesting
466    public Message[] getMessages(SearchParams params, MessageRetrievalListener listener)
467            throws MessagingException {
468        List<String> commands = new ArrayList<String>();
469        final String filter = params.mFilter;
470        // All servers MUST accept US-ASCII, so we'll send this as the CHARSET unless we're really
471        // dealing with a string that contains non-ascii characters
472        String charset = "US-ASCII";
473        if (!isAsciiString(filter)) {
474            charset = "UTF-8";
475        }
476        // This is the length of the string in octets (bytes), formatted as a string literal {n}
477        final String octetLength = "{" + filter.getBytes().length + "}";
478        // Break the command up into pieces ending with the string literal length
479        commands.add(ImapConstants.UID_SEARCH + " CHARSET " + charset + " OR FROM " + octetLength);
480        commands.add(filter + " (OR TO " + octetLength);
481        commands.add(filter + " (OR CC " + octetLength);
482        commands.add(filter + " (OR SUBJECT " + octetLength);
483        commands.add(filter + " BODY " + octetLength);
484        commands.add(filter + ")))");
485        return getMessagesInternal(complexSearchForUids(commands), listener);
486    }
487
488    /* package */ String[] complexSearchForUids(List<String> commands) throws MessagingException {
489        checkOpen();
490        try {
491            try {
492                return getSearchUids(mConnection.executeComplexCommand(commands, false));
493            } catch (ImapException e) {
494                return Utility.EMPTY_STRINGS; // not found;
495            } catch (IOException ioe) {
496                throw ioExceptionHandler(mConnection, ioe);
497            }
498        } finally {
499            destroyResponses();
500        }
501    }
502
503    @Override
504    @VisibleForTesting
505    public Message[] getMessages(int start, int end, MessageRetrievalListener listener)
506            throws MessagingException {
507        if (start < 1 || end < 1 || end < start) {
508            throw new MessagingException(String.format("Invalid range: %d %d", start, end));
509        }
510        LogUtils.d(Logging.LOG_TAG, "getMessages number " + start + " - " + end);
511        return getMessagesInternal(
512                searchForUids(String.format(Locale.US, "%d:%d NOT DELETED", start, end)), listener);
513    }
514
515    private String generateDateRangeCommand(final long startDate, final long endDate,
516            boolean useQuotes)
517            throws MessagingException {
518        // Dates must be formatted like: 7-Feb-1994. Time info within a date is not
519        // universally supported.
520        // XXX can I limit the maximum number of results?
521        final SimpleDateFormat formatter = new SimpleDateFormat("dd-LLL-yyyy", Locale.US);
522        formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
523        final String sinceDateStr = formatter.format(endDate);
524
525        StringBuilder queryParam = new StringBuilder();
526        queryParam.append( "1:* ");
527        // If the caller requests a startDate of zero, then ignore the BEFORE parameter.
528        // This makes sure that we can always query for the newest messages, even if our
529        // time is different from the imap server's time.
530        if (startDate != 0) {
531            final String beforeDateStr = formatter.format(startDate);
532            if (startDate < endDate) {
533                throw new MessagingException(String.format("Invalid date range: %s - %s",
534                        sinceDateStr, beforeDateStr));
535            }
536            queryParam.append("BEFORE ");
537            if (useQuotes) queryParam.append('\"');
538            queryParam.append(beforeDateStr);
539            if (useQuotes) queryParam.append('\"');
540            queryParam.append(" ");
541        }
542        queryParam.append("SINCE ");
543        if (useQuotes) queryParam.append('\"');
544        queryParam.append(sinceDateStr);
545        if (useQuotes) queryParam.append('\"');
546
547        return queryParam.toString();
548    }
549
550    @Override
551    @VisibleForTesting
552    public Message[] getMessages(long startDate, long endDate, MessageRetrievalListener listener)
553            throws MessagingException {
554        String [] uids = null;
555        String command = generateDateRangeCommand(startDate, endDate, false);
556        LogUtils.d(Logging.LOG_TAG, "getMessages dateRange " + command.toString());
557
558        try {
559            uids = searchForUids(command.toString(), false);
560        } catch (ImapException e) {
561            // TODO: This is a last minute hack to make certain servers work. Some servers
562            // demand that the date in the date range be surrounded by double quotes, other
563            // servers won't accept that. So if we can an ImapException using one method,
564            // try the other.
565            // See b/11183568
566            LogUtils.d(Logging.LOG_TAG, e, "query failed %s, trying alternate",
567                    command.toString());
568            command = generateDateRangeCommand(startDate, endDate, true);
569            try {
570                uids = searchForUids(command, true);
571            } catch (ImapException e2) {
572                LogUtils.w(Logging.LOG_TAG, e2, "query failed %s, fatal", command);
573                uids = null;
574            }
575        }
576        return getMessagesInternal(uids, listener);
577    }
578
579    @Override
580    @VisibleForTesting
581    public Message[] getMessages(String[] uids, MessageRetrievalListener listener)
582            throws MessagingException {
583        if (uids == null) {
584            uids = searchForUids("1:* NOT DELETED");
585        }
586        return getMessagesInternal(uids, listener);
587    }
588
589    public Message[] getMessagesInternal(String[] uids, MessageRetrievalListener listener) {
590        final ArrayList<Message> messages = new ArrayList<Message>(uids.length);
591        for (int i = 0; i < uids.length; i++) {
592            final String uid = uids[i];
593            final ImapMessage message = new ImapMessage(uid, this);
594            messages.add(message);
595            if (listener != null) {
596                listener.messageRetrieved(message);
597            }
598        }
599        return messages.toArray(Message.EMPTY_ARRAY);
600    }
601
602    @Override
603    public void fetch(Message[] messages, FetchProfile fp, MessageRetrievalListener listener)
604            throws MessagingException {
605        try {
606            fetchInternal(messages, fp, listener);
607        } catch (RuntimeException e) { // Probably a parser error.
608            LogUtils.w(Logging.LOG_TAG, "Exception detected: " + e.getMessage());
609            if (mConnection != null) {
610                mConnection.logLastDiscourse();
611            }
612            throw e;
613        }
614    }
615
616    public void fetchInternal(Message[] messages, FetchProfile fp,
617            MessageRetrievalListener listener) throws MessagingException {
618        if (messages.length == 0) {
619            return;
620        }
621        checkOpen();
622        HashMap<String, Message> messageMap = new HashMap<String, Message>();
623        for (Message m : messages) {
624            messageMap.put(m.getUid(), m);
625        }
626
627        /*
628         * Figure out what command we are going to run:
629         * FLAGS     - UID FETCH (FLAGS)
630         * ENVELOPE  - UID FETCH (INTERNALDATE UID RFC822.SIZE FLAGS BODY.PEEK[
631         *                            HEADER.FIELDS (date subject from content-type to cc)])
632         * STRUCTURE - UID FETCH (BODYSTRUCTURE)
633         * BODY_SANE - UID FETCH (BODY.PEEK[]<0.N>) where N = max bytes returned
634         * BODY      - UID FETCH (BODY.PEEK[])
635         * Part      - UID FETCH (BODY.PEEK[ID]) where ID = mime part ID
636         */
637
638        final LinkedHashSet<String> fetchFields = new LinkedHashSet<String>();
639
640        fetchFields.add(ImapConstants.UID);
641        if (fp.contains(FetchProfile.Item.FLAGS)) {
642            fetchFields.add(ImapConstants.FLAGS);
643        }
644        if (fp.contains(FetchProfile.Item.ENVELOPE)) {
645            fetchFields.add(ImapConstants.INTERNALDATE);
646            fetchFields.add(ImapConstants.RFC822_SIZE);
647            fetchFields.add(ImapConstants.FETCH_FIELD_HEADERS);
648        }
649        if (fp.contains(FetchProfile.Item.STRUCTURE)) {
650            fetchFields.add(ImapConstants.BODYSTRUCTURE);
651        }
652
653        if (fp.contains(FetchProfile.Item.BODY_SANE)) {
654            fetchFields.add(ImapConstants.FETCH_FIELD_BODY_PEEK_SANE);
655        }
656        if (fp.contains(FetchProfile.Item.BODY)) {
657            fetchFields.add(ImapConstants.FETCH_FIELD_BODY_PEEK);
658        }
659
660        // TODO Why are we only fetching the first part given?
661        final Part fetchPart = fp.getFirstPart();
662        if (fetchPart != null) {
663            final String[] partIds =
664                    fetchPart.getHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA);
665            // TODO Why can a single part have more than one Id? And why should we only fetch
666            // the first id if there are more than one?
667            if (partIds != null) {
668                fetchFields.add(ImapConstants.FETCH_FIELD_BODY_PEEK_BARE
669                        + "[" + partIds[0] + "]");
670            }
671        }
672
673        try {
674            mConnection.sendCommand(String.format(Locale.US,
675                    ImapConstants.UID_FETCH + " %s (%s)", ImapStore.joinMessageUids(messages),
676                    Utility.combine(fetchFields.toArray(new String[fetchFields.size()]), ' ')
677                    ), false);
678            ImapResponse response;
679            do {
680                response = null;
681                try {
682                    response = mConnection.readResponse();
683
684                    if (!response.isDataResponse(1, ImapConstants.FETCH)) {
685                        continue; // Ignore
686                    }
687                    final ImapList fetchList = response.getListOrEmpty(2);
688                    final String uid = fetchList.getKeyedStringOrEmpty(ImapConstants.UID)
689                            .getString();
690                    if (TextUtils.isEmpty(uid)) continue;
691
692                    ImapMessage message = (ImapMessage) messageMap.get(uid);
693                    if (message == null) continue;
694
695                    if (fp.contains(FetchProfile.Item.FLAGS)) {
696                        final ImapList flags =
697                            fetchList.getKeyedListOrEmpty(ImapConstants.FLAGS);
698                        for (int i = 0, count = flags.size(); i < count; i++) {
699                            final ImapString flag = flags.getStringOrEmpty(i);
700                            if (flag.is(ImapConstants.FLAG_DELETED)) {
701                                message.setFlagInternal(Flag.DELETED, true);
702                            } else if (flag.is(ImapConstants.FLAG_ANSWERED)) {
703                                message.setFlagInternal(Flag.ANSWERED, true);
704                            } else if (flag.is(ImapConstants.FLAG_SEEN)) {
705                                message.setFlagInternal(Flag.SEEN, true);
706                            } else if (flag.is(ImapConstants.FLAG_FLAGGED)) {
707                                message.setFlagInternal(Flag.FLAGGED, true);
708                            }
709                        }
710                    }
711                    if (fp.contains(FetchProfile.Item.ENVELOPE)) {
712                        final Date internalDate = fetchList.getKeyedStringOrEmpty(
713                                ImapConstants.INTERNALDATE).getDateOrNull();
714                        final int size = fetchList.getKeyedStringOrEmpty(
715                                ImapConstants.RFC822_SIZE).getNumberOrZero();
716                        final String header = fetchList.getKeyedStringOrEmpty(
717                                ImapConstants.BODY_BRACKET_HEADER, true).getString();
718
719                        message.setInternalDate(internalDate);
720                        message.setSize(size);
721                        message.parse(Utility.streamFromAsciiString(header));
722                    }
723                    if (fp.contains(FetchProfile.Item.STRUCTURE)) {
724                        ImapList bs = fetchList.getKeyedListOrEmpty(
725                                ImapConstants.BODYSTRUCTURE);
726                        if (!bs.isEmpty()) {
727                            try {
728                                parseBodyStructure(bs, message, ImapConstants.TEXT);
729                            } catch (MessagingException e) {
730                                if (Logging.LOGD) {
731                                    LogUtils.v(Logging.LOG_TAG, e, "Error handling message");
732                                }
733                                message.setBody(null);
734                            }
735                        }
736                    }
737                    if (fp.contains(FetchProfile.Item.BODY)
738                            || fp.contains(FetchProfile.Item.BODY_SANE)) {
739                        // Body is keyed by "BODY[]...".
740                        // Previously used "BODY[..." but this can be confused with "BODY[HEADER..."
741                        // TODO Should we accept "RFC822" as well??
742                        ImapString body = fetchList.getKeyedStringOrEmpty("BODY[]", true);
743                        InputStream bodyStream = body.getAsStream();
744                        message.parse(bodyStream);
745                    }
746                    if (fetchPart != null) {
747                        InputStream bodyStream =
748                                fetchList.getKeyedStringOrEmpty("BODY[", true).getAsStream();
749                        String encodings[] = fetchPart.getHeader(
750                                MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING);
751
752                        String contentTransferEncoding = null;
753                        if (encodings != null && encodings.length > 0) {
754                            contentTransferEncoding = encodings[0];
755                        } else {
756                            // According to http://tools.ietf.org/html/rfc2045#section-6.1
757                            // "7bit" is the default.
758                            contentTransferEncoding = "7bit";
759                        }
760
761                        try {
762                            // TODO Don't create 2 temp files.
763                            // decodeBody creates BinaryTempFileBody, but we could avoid this
764                            // if we implement ImapStringBody.
765                            // (We'll need to share a temp file.  Protect it with a ref-count.)
766                            fetchPart.setBody(decodeBody(bodyStream, contentTransferEncoding,
767                                    fetchPart.getSize(), listener));
768                        } catch(Exception e) {
769                            // TODO: Figure out what kinds of exceptions might actually be thrown
770                            // from here. This blanket catch-all is because we're not sure what to
771                            // do if we don't have a contentTransferEncoding, and we don't have
772                            // time to figure out what exceptions might be thrown.
773                            LogUtils.e(Logging.LOG_TAG, "Error fetching body %s", e);
774                        }
775                    }
776
777                    if (listener != null) {
778                        listener.messageRetrieved(message);
779                    }
780                } finally {
781                    destroyResponses();
782                }
783            } while (!response.isTagged());
784        } catch (IOException ioe) {
785            throw ioExceptionHandler(mConnection, ioe);
786        }
787    }
788
789    /**
790     * Removes any content transfer encoding from the stream and returns a Body.
791     * This code is taken/condensed from MimeUtility.decodeBody
792     */
793    private static Body decodeBody(InputStream in, String contentTransferEncoding, int size,
794            MessageRetrievalListener listener) throws IOException {
795        // Get a properly wrapped input stream
796        in = MimeUtility.getInputStreamForContentTransferEncoding(in, contentTransferEncoding);
797        BinaryTempFileBody tempBody = new BinaryTempFileBody();
798        OutputStream out = tempBody.getOutputStream();
799        try {
800            byte[] buffer = new byte[COPY_BUFFER_SIZE];
801            int n = 0;
802            int count = 0;
803            while (-1 != (n = in.read(buffer))) {
804                out.write(buffer, 0, n);
805                count += n;
806                if (listener != null) {
807                    if (size == 0) {
808                        // We don't know how big the file is, so just fake it.
809                        listener.loadAttachmentProgress((int)Math.ceil(100 * (1-1.0/count)));
810                    } else {
811                        listener.loadAttachmentProgress(count * 100 / size);
812                    }
813                }
814            }
815        } catch (Base64DataException bde) {
816            String warning = "\n\n" + MailActivityEmail.getMessageDecodeErrorString();
817            out.write(warning.getBytes());
818        } finally {
819            out.close();
820        }
821        return tempBody;
822    }
823
824    @Override
825    public Flag[] getPermanentFlags() {
826        return PERMANENT_FLAGS;
827    }
828
829    /**
830     * Handle any untagged responses that the caller doesn't care to handle themselves.
831     * @param responses
832     */
833    private void handleUntaggedResponses(List<ImapResponse> responses) {
834        for (ImapResponse response : responses) {
835            handleUntaggedResponse(response);
836        }
837    }
838
839    /**
840     * Handle an untagged response that the caller doesn't care to handle themselves.
841     * @param response
842     */
843    private void handleUntaggedResponse(ImapResponse response) {
844        if (response.isDataResponse(1, ImapConstants.EXISTS)) {
845            mMessageCount = response.getStringOrEmpty(0).getNumberOrZero();
846        }
847    }
848
849    private static void parseBodyStructure(ImapList bs, Part part, String id)
850            throws MessagingException {
851        if (bs.getElementOrNone(0).isList()) {
852            /*
853             * This is a multipart/*
854             */
855            MimeMultipart mp = new MimeMultipart();
856            for (int i = 0, count = bs.size(); i < count; i++) {
857                ImapElement e = bs.getElementOrNone(i);
858                if (e.isList()) {
859                    /*
860                     * For each part in the message we're going to add a new BodyPart and parse
861                     * into it.
862                     */
863                    MimeBodyPart bp = new MimeBodyPart();
864                    if (id.equals(ImapConstants.TEXT)) {
865                        parseBodyStructure(bs.getListOrEmpty(i), bp, Integer.toString(i + 1));
866
867                    } else {
868                        parseBodyStructure(bs.getListOrEmpty(i), bp, id + "." + (i + 1));
869                    }
870                    mp.addBodyPart(bp);
871
872                } else {
873                    if (e.isString()) {
874                        mp.setSubType(bs.getStringOrEmpty(i).getString().toLowerCase(Locale.US));
875                    }
876                    break; // Ignore the rest of the list.
877                }
878            }
879            part.setBody(mp);
880        } else {
881            /*
882             * This is a body. We need to add as much information as we can find out about
883             * it to the Part.
884             */
885
886            /*
887             body type
888             body subtype
889             body parameter parenthesized list
890             body id
891             body description
892             body encoding
893             body size
894             */
895
896            final ImapString type = bs.getStringOrEmpty(0);
897            final ImapString subType = bs.getStringOrEmpty(1);
898            final String mimeType =
899                    (type.getString() + "/" + subType.getString()).toLowerCase(Locale.US);
900
901            final ImapList bodyParams = bs.getListOrEmpty(2);
902            final ImapString cid = bs.getStringOrEmpty(3);
903            final ImapString encoding = bs.getStringOrEmpty(5);
904            final int size = bs.getStringOrEmpty(6).getNumberOrZero();
905
906            if (MimeUtility.mimeTypeMatches(mimeType, MimeUtility.MIME_TYPE_RFC822)) {
907                // A body type of type MESSAGE and subtype RFC822
908                // contains, immediately after the basic fields, the
909                // envelope structure, body structure, and size in
910                // text lines of the encapsulated message.
911                // [MESSAGE, RFC822, [NAME, filename.eml], NIL, NIL, 7BIT, 5974, NIL,
912                //     [INLINE, [FILENAME*0, Fwd: Xxx..., FILENAME*1, filename.eml]], NIL]
913                /*
914                 * This will be caught by fetch and handled appropriately.
915                 */
916                throw new MessagingException("BODYSTRUCTURE " + MimeUtility.MIME_TYPE_RFC822
917                        + " not yet supported.");
918            }
919
920            /*
921             * Set the content type with as much information as we know right now.
922             */
923            final StringBuilder contentType = new StringBuilder(mimeType);
924
925            /*
926             * If there are body params we might be able to get some more information out
927             * of them.
928             */
929            for (int i = 1, count = bodyParams.size(); i < count; i += 2) {
930
931                // TODO We need to convert " into %22, but
932                // because MimeUtility.getHeaderParameter doesn't recognize it,
933                // we can't fix it for now.
934                contentType.append(String.format(";\n %s=\"%s\"",
935                        bodyParams.getStringOrEmpty(i - 1).getString(),
936                        bodyParams.getStringOrEmpty(i).getString()));
937            }
938
939            part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType.toString());
940
941            // Extension items
942            final ImapList bodyDisposition;
943
944            if (type.is(ImapConstants.TEXT) && bs.getElementOrNone(9).isList()) {
945                // If media-type is TEXT, 9th element might be: [body-fld-lines] := number
946                // So, if it's not a list, use 10th element.
947                // (Couldn't find evidence in the RFC if it's ALWAYS 10th element.)
948                bodyDisposition = bs.getListOrEmpty(9);
949            } else {
950                bodyDisposition = bs.getListOrEmpty(8);
951            }
952
953            final StringBuilder contentDisposition = new StringBuilder();
954
955            if (bodyDisposition.size() > 0) {
956                final String bodyDisposition0Str =
957                        bodyDisposition.getStringOrEmpty(0).getString().toLowerCase(Locale.US);
958                if (!TextUtils.isEmpty(bodyDisposition0Str)) {
959                    contentDisposition.append(bodyDisposition0Str);
960                }
961
962                final ImapList bodyDispositionParams = bodyDisposition.getListOrEmpty(1);
963                if (!bodyDispositionParams.isEmpty()) {
964                    /*
965                     * If there is body disposition information we can pull some more
966                     * information about the attachment out.
967                     */
968                    for (int i = 1, count = bodyDispositionParams.size(); i < count; i += 2) {
969
970                        // TODO We need to convert " into %22.  See above.
971                        contentDisposition.append(String.format(Locale.US, ";\n %s=\"%s\"",
972                                bodyDispositionParams.getStringOrEmpty(i - 1)
973                                        .getString().toLowerCase(Locale.US),
974                                bodyDispositionParams.getStringOrEmpty(i).getString()));
975                    }
976                }
977            }
978
979            if ((size > 0)
980                    && (MimeUtility.getHeaderParameter(contentDisposition.toString(), "size")
981                            == null)) {
982                contentDisposition.append(String.format(Locale.US, ";\n size=%d", size));
983            }
984
985            if (contentDisposition.length() > 0) {
986                /*
987                 * Set the content disposition containing at least the size. Attachment
988                 * handling code will use this down the road.
989                 */
990                part.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION,
991                        contentDisposition.toString());
992            }
993
994            /*
995             * Set the Content-Transfer-Encoding header. Attachment code will use this
996             * to parse the body.
997             */
998            if (!encoding.isEmpty()) {
999                part.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING,
1000                        encoding.getString());
1001            }
1002
1003            /*
1004             * Set the Content-ID header.
1005             */
1006            if (!cid.isEmpty()) {
1007                part.setHeader(MimeHeader.HEADER_CONTENT_ID, cid.getString());
1008            }
1009
1010            if (size > 0) {
1011                if (part instanceof ImapMessage) {
1012                    ((ImapMessage) part).setSize(size);
1013                } else if (part instanceof MimeBodyPart) {
1014                    ((MimeBodyPart) part).setSize(size);
1015                } else {
1016                    throw new MessagingException("Unknown part type " + part.toString());
1017                }
1018            }
1019            part.setHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA, id);
1020        }
1021
1022    }
1023
1024    /**
1025     * Appends the given messages to the selected folder. This implementation also determines
1026     * the new UID of the given message on the IMAP server and sets the Message's UID to the
1027     * new server UID.
1028     */
1029    @Override
1030    public void appendMessages(Message[] messages) throws MessagingException {
1031        checkOpen();
1032        try {
1033            for (Message message : messages) {
1034                // Create output count
1035                CountingOutputStream out = new CountingOutputStream();
1036                EOLConvertingOutputStream eolOut = new EOLConvertingOutputStream(out);
1037                message.writeTo(eolOut);
1038                eolOut.flush();
1039                // Create flag list (most often this will be "\SEEN")
1040                String flagList = "";
1041                Flag[] flags = message.getFlags();
1042                if (flags.length > 0) {
1043                    StringBuilder sb = new StringBuilder();
1044                    for (int i = 0, count = flags.length; i < count; i++) {
1045                        Flag flag = flags[i];
1046                        if (flag == Flag.SEEN) {
1047                            sb.append(" " + ImapConstants.FLAG_SEEN);
1048                        } else if (flag == Flag.FLAGGED) {
1049                            sb.append(" " + ImapConstants.FLAG_FLAGGED);
1050                        }
1051                    }
1052                    if (sb.length() > 0) {
1053                        flagList = sb.substring(1);
1054                    }
1055                }
1056
1057                mConnection.sendCommand(
1058                        String.format(Locale.US, ImapConstants.APPEND + " \"%s\" (%s) {%d}",
1059                                ImapStore.encodeFolderName(mName, mStore.mPathPrefix),
1060                                flagList,
1061                                out.getCount()), false);
1062                ImapResponse response;
1063                do {
1064                    response = mConnection.readResponse();
1065                    if (response.isContinuationRequest()) {
1066                        eolOut = new EOLConvertingOutputStream(
1067                                mConnection.mTransport.getOutputStream());
1068                        message.writeTo(eolOut);
1069                        eolOut.write('\r');
1070                        eolOut.write('\n');
1071                        eolOut.flush();
1072                    } else if (!response.isTagged()) {
1073                        handleUntaggedResponse(response);
1074                    }
1075                } while (!response.isTagged());
1076
1077                // TODO Why not check the response?
1078
1079                /*
1080                 * Try to recover the UID of the message from an APPENDUID response.
1081                 * e.g. 11 OK [APPENDUID 2 238268] APPEND completed
1082                 */
1083                final ImapList appendList = response.getListOrEmpty(1);
1084                if ((appendList.size() >= 3) && appendList.is(0, ImapConstants.APPENDUID)) {
1085                    String serverUid = appendList.getStringOrEmpty(2).getString();
1086                    if (!TextUtils.isEmpty(serverUid)) {
1087                        message.setUid(serverUid);
1088                        continue;
1089                    }
1090                }
1091
1092                /*
1093                 * Try to find the UID of the message we just appended using the
1094                 * Message-ID header.  If there are more than one response, take the
1095                 * last one, as it's most likely the newest (the one we just uploaded).
1096                 */
1097                final String messageId = message.getMessageId();
1098                if (messageId == null || messageId.length() == 0) {
1099                    continue;
1100                }
1101                // Most servers don't care about parenthesis in the search query [and, some
1102                // fail to work if they are used]
1103                String[] uids = searchForUids(
1104                        String.format(Locale.US, "HEADER MESSAGE-ID %s", messageId));
1105                if (uids.length > 0) {
1106                    message.setUid(uids[0]);
1107                }
1108                // However, there's at least one server [AOL] that fails to work unless there
1109                // are parenthesis, so, try this as a last resort
1110                uids = searchForUids(String.format(Locale.US, "(HEADER MESSAGE-ID %s)", messageId));
1111                if (uids.length > 0) {
1112                    message.setUid(uids[0]);
1113                }
1114            }
1115        } catch (IOException ioe) {
1116            throw ioExceptionHandler(mConnection, ioe);
1117        } finally {
1118            destroyResponses();
1119        }
1120    }
1121
1122    @Override
1123    public Message[] expunge() throws MessagingException {
1124        checkOpen();
1125        try {
1126            handleUntaggedResponses(mConnection.executeSimpleCommand(ImapConstants.EXPUNGE));
1127        } catch (IOException ioe) {
1128            throw ioExceptionHandler(mConnection, ioe);
1129        } finally {
1130            destroyResponses();
1131        }
1132        return null;
1133    }
1134
1135    @Override
1136    public void setFlags(Message[] messages, Flag[] flags, boolean value)
1137            throws MessagingException {
1138        checkOpen();
1139
1140        String allFlags = "";
1141        if (flags.length > 0) {
1142            StringBuilder flagList = new StringBuilder();
1143            for (int i = 0, count = flags.length; i < count; i++) {
1144                Flag flag = flags[i];
1145                if (flag == Flag.SEEN) {
1146                    flagList.append(" " + ImapConstants.FLAG_SEEN);
1147                } else if (flag == Flag.DELETED) {
1148                    flagList.append(" " + ImapConstants.FLAG_DELETED);
1149                } else if (flag == Flag.FLAGGED) {
1150                    flagList.append(" " + ImapConstants.FLAG_FLAGGED);
1151                } else if (flag == Flag.ANSWERED) {
1152                    flagList.append(" " + ImapConstants.FLAG_ANSWERED);
1153                }
1154            }
1155            allFlags = flagList.substring(1);
1156        }
1157        try {
1158            mConnection.executeSimpleCommand(String.format(Locale.US,
1159                    ImapConstants.UID_STORE + " %s %s" + ImapConstants.FLAGS_SILENT + " (%s)",
1160                    ImapStore.joinMessageUids(messages),
1161                    value ? "+" : "-",
1162                    allFlags));
1163
1164        } catch (IOException ioe) {
1165            throw ioExceptionHandler(mConnection, ioe);
1166        } finally {
1167            destroyResponses();
1168        }
1169    }
1170
1171    /**
1172     * Persists this folder. We will always perform the proper database operation (e.g.
1173     * 'save' or 'update'). As an optimization, if a folder has not been modified, no
1174     * database operations are performed.
1175     */
1176    void save(Context context) {
1177        final Mailbox mailbox = mMailbox;
1178        if (!mailbox.isSaved()) {
1179            mailbox.save(context);
1180            mHash = mailbox.getHashes();
1181        } else {
1182            Object[] hash = mailbox.getHashes();
1183            if (!Arrays.equals(mHash, hash)) {
1184                mailbox.update(context, mailbox.toContentValues());
1185                mHash = hash;  // Save updated hash
1186            }
1187        }
1188    }
1189
1190    /**
1191     * Selects the folder for use. Before performing any operations on this folder, it
1192     * must be selected.
1193     */
1194    private void doSelect() throws IOException, MessagingException {
1195        final List<ImapResponse> responses = mConnection.executeSimpleCommand(
1196                String.format(Locale.US, ImapConstants.SELECT + " \"%s\"",
1197                        ImapStore.encodeFolderName(mName, mStore.mPathPrefix)));
1198
1199        // Assume the folder is opened read-write; unless we are notified otherwise
1200        mMode = OpenMode.READ_WRITE;
1201        int messageCount = -1;
1202        for (ImapResponse response : responses) {
1203            if (response.isDataResponse(1, ImapConstants.EXISTS)) {
1204                messageCount = response.getStringOrEmpty(0).getNumberOrZero();
1205            } else if (response.isOk()) {
1206                final ImapString responseCode = response.getResponseCodeOrEmpty();
1207                if (responseCode.is(ImapConstants.READ_ONLY)) {
1208                    mMode = OpenMode.READ_ONLY;
1209                } else if (responseCode.is(ImapConstants.READ_WRITE)) {
1210                    mMode = OpenMode.READ_WRITE;
1211                }
1212            } else if (response.isTagged()) { // Not OK
1213                throw new MessagingException("Can't open mailbox: "
1214                        + response.getStatusResponseTextOrEmpty());
1215            }
1216        }
1217        if (messageCount == -1) {
1218            throw new MessagingException("Did not find message count during select");
1219        }
1220        mMessageCount = messageCount;
1221        mExists = true;
1222    }
1223
1224    private void checkOpen() throws MessagingException {
1225        if (!isOpen()) {
1226            throw new MessagingException("Folder " + mName + " is not open.");
1227        }
1228    }
1229
1230    private MessagingException ioExceptionHandler(ImapConnection connection, IOException ioe) {
1231        if (MailActivityEmail.DEBUG) {
1232            LogUtils.d(Logging.LOG_TAG, "IO Exception detected: ", ioe);
1233        }
1234        connection.close();
1235        if (connection == mConnection) {
1236            mConnection = null; // To prevent close() from returning the connection to the pool.
1237            close(false);
1238        }
1239        return new MessagingException("IO Error", ioe);
1240    }
1241
1242    @Override
1243    public boolean equals(Object o) {
1244        if (o instanceof ImapFolder) {
1245            return ((ImapFolder)o).mName.equals(mName);
1246        }
1247        return super.equals(o);
1248    }
1249
1250    @Override
1251    public Message createMessage(String uid) {
1252        return new ImapMessage(uid, this);
1253    }
1254}
1255