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