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.Email;
25import com.android.email.mail.store.ImapStore.ImapException;
26import com.android.email.mail.store.ImapStore.ImapMessage;
27import com.android.email.mail.store.imap.ImapConstants;
28import com.android.email.mail.store.imap.ImapElement;
29import com.android.email.mail.store.imap.ImapList;
30import com.android.email.mail.store.imap.ImapResponse;
31import com.android.email.mail.store.imap.ImapString;
32import com.android.email.mail.store.imap.ImapUtility;
33import com.android.email.mail.transport.CountingOutputStream;
34import com.android.email.mail.transport.EOLConvertingOutputStream;
35import com.android.emailcommon.Logging;
36import com.android.emailcommon.internet.BinaryTempFileBody;
37import com.android.emailcommon.internet.MimeBodyPart;
38import com.android.emailcommon.internet.MimeHeader;
39import com.android.emailcommon.internet.MimeMultipart;
40import com.android.emailcommon.internet.MimeUtility;
41import com.android.emailcommon.mail.AuthenticationFailedException;
42import com.android.emailcommon.mail.Body;
43import com.android.emailcommon.mail.FetchProfile;
44import com.android.emailcommon.mail.Flag;
45import com.android.emailcommon.mail.Folder;
46import com.android.emailcommon.mail.Message;
47import com.android.emailcommon.mail.MessagingException;
48import com.android.emailcommon.mail.Part;
49import com.android.emailcommon.provider.Mailbox;
50import com.android.emailcommon.service.SearchParams;
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 contentType = fetchPart.getContentType();
653                        String contentTransferEncoding = fetchPart.getHeader(
654                                MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING)[0];
655
656                        // TODO Don't create 2 temp files.
657                        // decodeBody creates BinaryTempFileBody, but we could avoid this
658                        // if we implement ImapStringBody.
659                        // (We'll need to share a temp file.  Protect it with a ref-count.)
660                        fetchPart.setBody(decodeBody(bodyStream, contentTransferEncoding,
661                                fetchPart.getSize(), listener));
662                    }
663
664                    if (listener != null) {
665                        listener.messageRetrieved(message);
666                    }
667                } finally {
668                    destroyResponses();
669                }
670            } while (!response.isTagged());
671        } catch (IOException ioe) {
672            throw ioExceptionHandler(mConnection, ioe);
673        }
674    }
675
676    /**
677     * Removes any content transfer encoding from the stream and returns a Body.
678     * This code is taken/condensed from MimeUtility.decodeBody
679     */
680    private Body decodeBody(InputStream in, String contentTransferEncoding, int size,
681            MessageRetrievalListener listener) throws IOException {
682        // Get a properly wrapped input stream
683        in = MimeUtility.getInputStreamForContentTransferEncoding(in, contentTransferEncoding);
684        BinaryTempFileBody tempBody = new BinaryTempFileBody();
685        OutputStream out = tempBody.getOutputStream();
686        try {
687            byte[] buffer = new byte[COPY_BUFFER_SIZE];
688            int n = 0;
689            int count = 0;
690            while (-1 != (n = in.read(buffer))) {
691                out.write(buffer, 0, n);
692                count += n;
693                if (listener != null) {
694                    listener.loadAttachmentProgress(count * 100 / size);
695                }
696            }
697        } catch (Base64DataException bde) {
698            String warning = "\n\n" + Email.getMessageDecodeErrorString();
699            out.write(warning.getBytes());
700        } finally {
701            out.close();
702        }
703        return tempBody;
704    }
705
706    @Override
707    public Flag[] getPermanentFlags() {
708        return PERMANENT_FLAGS;
709    }
710
711    /**
712     * Handle any untagged responses that the caller doesn't care to handle themselves.
713     * @param responses
714     */
715    private void handleUntaggedResponses(List<ImapResponse> responses) {
716        for (ImapResponse response : responses) {
717            handleUntaggedResponse(response);
718        }
719    }
720
721    /**
722     * Handle an untagged response that the caller doesn't care to handle themselves.
723     * @param response
724     */
725    private void handleUntaggedResponse(ImapResponse response) {
726        if (response.isDataResponse(1, ImapConstants.EXISTS)) {
727            mMessageCount = response.getStringOrEmpty(0).getNumberOrZero();
728        }
729    }
730
731    private static void parseBodyStructure(ImapList bs, Part part, String id)
732            throws MessagingException {
733        if (bs.getElementOrNone(0).isList()) {
734            /*
735             * This is a multipart/*
736             */
737            MimeMultipart mp = new MimeMultipart();
738            for (int i = 0, count = bs.size(); i < count; i++) {
739                ImapElement e = bs.getElementOrNone(i);
740                if (e.isList()) {
741                    /*
742                     * For each part in the message we're going to add a new BodyPart and parse
743                     * into it.
744                     */
745                    MimeBodyPart bp = new MimeBodyPart();
746                    if (id.equals(ImapConstants.TEXT)) {
747                        parseBodyStructure(bs.getListOrEmpty(i), bp, Integer.toString(i + 1));
748
749                    } else {
750                        parseBodyStructure(bs.getListOrEmpty(i), bp, id + "." + (i + 1));
751                    }
752                    mp.addBodyPart(bp);
753
754                } else {
755                    if (e.isString()) {
756                        mp.setSubType(bs.getStringOrEmpty(i).getString().toLowerCase());
757                    }
758                    break; // Ignore the rest of the list.
759                }
760            }
761            part.setBody(mp);
762        } else {
763            /*
764             * This is a body. We need to add as much information as we can find out about
765             * it to the Part.
766             */
767
768            /*
769             body type
770             body subtype
771             body parameter parenthesized list
772             body id
773             body description
774             body encoding
775             body size
776             */
777
778            final ImapString type = bs.getStringOrEmpty(0);
779            final ImapString subType = bs.getStringOrEmpty(1);
780            final String mimeType =
781                    (type.getString() + "/" + subType.getString()).toLowerCase();
782
783            final ImapList bodyParams = bs.getListOrEmpty(2);
784            final ImapString cid = bs.getStringOrEmpty(3);
785            final ImapString encoding = bs.getStringOrEmpty(5);
786            final int size = bs.getStringOrEmpty(6).getNumberOrZero();
787
788            if (MimeUtility.mimeTypeMatches(mimeType, MimeUtility.MIME_TYPE_RFC822)) {
789                // A body type of type MESSAGE and subtype RFC822
790                // contains, immediately after the basic fields, the
791                // envelope structure, body structure, and size in
792                // text lines of the encapsulated message.
793                // [MESSAGE, RFC822, [NAME, filename.eml], NIL, NIL, 7BIT, 5974, NIL,
794                //     [INLINE, [FILENAME*0, Fwd: Xxx..., FILENAME*1, filename.eml]], NIL]
795                /*
796                 * This will be caught by fetch and handled appropriately.
797                 */
798                throw new MessagingException("BODYSTRUCTURE " + MimeUtility.MIME_TYPE_RFC822
799                        + " not yet supported.");
800            }
801
802            /*
803             * Set the content type with as much information as we know right now.
804             */
805            final StringBuilder contentType = new StringBuilder(mimeType);
806
807            /*
808             * If there are body params we might be able to get some more information out
809             * of them.
810             */
811            for (int i = 1, count = bodyParams.size(); i < count; i += 2) {
812
813                // TODO We need to convert " into %22, but
814                // because MimeUtility.getHeaderParameter doesn't recognize it,
815                // we can't fix it for now.
816                contentType.append(String.format(";\n %s=\"%s\"",
817                        bodyParams.getStringOrEmpty(i - 1).getString(),
818                        bodyParams.getStringOrEmpty(i).getString()));
819            }
820
821            part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType.toString());
822
823            // Extension items
824            final ImapList bodyDisposition;
825
826            if (type.is(ImapConstants.TEXT) && bs.getElementOrNone(9).isList()) {
827                // If media-type is TEXT, 9th element might be: [body-fld-lines] := number
828                // So, if it's not a list, use 10th element.
829                // (Couldn't find evidence in the RFC if it's ALWAYS 10th element.)
830                bodyDisposition = bs.getListOrEmpty(9);
831            } else {
832                bodyDisposition = bs.getListOrEmpty(8);
833            }
834
835            final StringBuilder contentDisposition = new StringBuilder();
836
837            if (bodyDisposition.size() > 0) {
838                final String bodyDisposition0Str =
839                        bodyDisposition.getStringOrEmpty(0).getString().toLowerCase();
840                if (!TextUtils.isEmpty(bodyDisposition0Str)) {
841                    contentDisposition.append(bodyDisposition0Str);
842                }
843
844                final ImapList bodyDispositionParams = bodyDisposition.getListOrEmpty(1);
845                if (!bodyDispositionParams.isEmpty()) {
846                    /*
847                     * If there is body disposition information we can pull some more
848                     * information about the attachment out.
849                     */
850                    for (int i = 1, count = bodyDispositionParams.size(); i < count; i += 2) {
851
852                        // TODO We need to convert " into %22.  See above.
853                        contentDisposition.append(String.format(";\n %s=\"%s\"",
854                                bodyDispositionParams.getStringOrEmpty(i - 1)
855                                        .getString().toLowerCase(),
856                                bodyDispositionParams.getStringOrEmpty(i).getString()));
857                    }
858                }
859            }
860
861            if ((size > 0)
862                    && (MimeUtility.getHeaderParameter(contentDisposition.toString(), "size")
863                            == null)) {
864                contentDisposition.append(String.format(";\n size=%d", size));
865            }
866
867            if (contentDisposition.length() > 0) {
868                /*
869                 * Set the content disposition containing at least the size. Attachment
870                 * handling code will use this down the road.
871                 */
872                part.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION,
873                        contentDisposition.toString());
874            }
875
876            /*
877             * Set the Content-Transfer-Encoding header. Attachment code will use this
878             * to parse the body.
879             */
880            if (!encoding.isEmpty()) {
881                part.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING,
882                        encoding.getString());
883            }
884
885            /*
886             * Set the Content-ID header.
887             */
888            if (!cid.isEmpty()) {
889                part.setHeader(MimeHeader.HEADER_CONTENT_ID, cid.getString());
890            }
891
892            if (size > 0) {
893                if (part instanceof ImapMessage) {
894                    ((ImapMessage) part).setSize(size);
895                } else if (part instanceof MimeBodyPart) {
896                    ((MimeBodyPart) part).setSize(size);
897                } else {
898                    throw new MessagingException("Unknown part type " + part.toString());
899                }
900            }
901            part.setHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA, id);
902        }
903
904    }
905
906    /**
907     * Appends the given messages to the selected folder. This implementation also determines
908     * the new UID of the given message on the IMAP server and sets the Message's UID to the
909     * new server UID.
910     */
911    @Override
912    public void appendMessages(Message[] messages) throws MessagingException {
913        checkOpen();
914        try {
915            for (Message message : messages) {
916                // Create output count
917                CountingOutputStream out = new CountingOutputStream();
918                EOLConvertingOutputStream eolOut = new EOLConvertingOutputStream(out);
919                message.writeTo(eolOut);
920                eolOut.flush();
921                // Create flag list (most often this will be "\SEEN")
922                String flagList = "";
923                Flag[] flags = message.getFlags();
924                if (flags.length > 0) {
925                    StringBuilder sb = new StringBuilder();
926                    for (int i = 0, count = flags.length; i < count; i++) {
927                        Flag flag = flags[i];
928                        if (flag == Flag.SEEN) {
929                            sb.append(" " + ImapConstants.FLAG_SEEN);
930                        } else if (flag == Flag.FLAGGED) {
931                            sb.append(" " + ImapConstants.FLAG_FLAGGED);
932                        }
933                    }
934                    if (sb.length() > 0) {
935                        flagList = sb.substring(1);
936                    }
937                }
938
939                mConnection.sendCommand(
940                        String.format(ImapConstants.APPEND + " \"%s\" (%s) {%d}",
941                                ImapStore.encodeFolderName(mName, mStore.mPathPrefix),
942                                flagList,
943                                out.getCount()), false);
944                ImapResponse response;
945                do {
946                    response = mConnection.readResponse();
947                    if (response.isContinuationRequest()) {
948                        eolOut = new EOLConvertingOutputStream(
949                                mConnection.mTransport.getOutputStream());
950                        message.writeTo(eolOut);
951                        eolOut.write('\r');
952                        eolOut.write('\n');
953                        eolOut.flush();
954                    } else if (!response.isTagged()) {
955                        handleUntaggedResponse(response);
956                    }
957                } while (!response.isTagged());
958
959                // TODO Why not check the response?
960
961                /*
962                 * Try to recover the UID of the message from an APPENDUID response.
963                 * e.g. 11 OK [APPENDUID 2 238268] APPEND completed
964                 */
965                final ImapList appendList = response.getListOrEmpty(1);
966                if ((appendList.size() >= 3) && appendList.is(0, ImapConstants.APPENDUID)) {
967                    String serverUid = appendList.getStringOrEmpty(2).getString();
968                    if (!TextUtils.isEmpty(serverUid)) {
969                        message.setUid(serverUid);
970                        continue;
971                    }
972                }
973
974                /*
975                 * Try to find the UID of the message we just appended using the
976                 * Message-ID header.  If there are more than one response, take the
977                 * last one, as it's most likely the newest (the one we just uploaded).
978                 */
979                String messageId = message.getMessageId();
980                if (messageId == null || messageId.length() == 0) {
981                    continue;
982                }
983                // Most servers don't care about parenthesis in the search query [and, some
984                // fail to work if they are used]
985                String[] uids = searchForUids(String.format("HEADER MESSAGE-ID %s", messageId));
986                if (uids.length > 0) {
987                    message.setUid(uids[0]);
988                }
989                // However, there's at least one server [AOL] that fails to work unless there
990                // are parenthesis, so, try this as a last resort
991                uids = searchForUids(String.format("(HEADER MESSAGE-ID %s)", messageId));
992                if (uids.length > 0) {
993                    message.setUid(uids[0]);
994                }
995            }
996        } catch (IOException ioe) {
997            throw ioExceptionHandler(mConnection, ioe);
998        } finally {
999            destroyResponses();
1000        }
1001    }
1002
1003    @Override
1004    public Message[] expunge() throws MessagingException {
1005        checkOpen();
1006        try {
1007            handleUntaggedResponses(mConnection.executeSimpleCommand(ImapConstants.EXPUNGE));
1008        } catch (IOException ioe) {
1009            throw ioExceptionHandler(mConnection, ioe);
1010        } finally {
1011            destroyResponses();
1012        }
1013        return null;
1014    }
1015
1016    @Override
1017    public void setFlags(Message[] messages, Flag[] flags, boolean value)
1018            throws MessagingException {
1019        checkOpen();
1020
1021        String allFlags = "";
1022        if (flags.length > 0) {
1023            StringBuilder flagList = new StringBuilder();
1024            for (int i = 0, count = flags.length; i < count; i++) {
1025                Flag flag = flags[i];
1026                if (flag == Flag.SEEN) {
1027                    flagList.append(" " + ImapConstants.FLAG_SEEN);
1028                } else if (flag == Flag.DELETED) {
1029                    flagList.append(" " + ImapConstants.FLAG_DELETED);
1030                } else if (flag == Flag.FLAGGED) {
1031                    flagList.append(" " + ImapConstants.FLAG_FLAGGED);
1032                } else if (flag == Flag.ANSWERED) {
1033                    flagList.append(" " + ImapConstants.FLAG_ANSWERED);
1034                }
1035            }
1036            allFlags = flagList.substring(1);
1037        }
1038        try {
1039            mConnection.executeSimpleCommand(String.format(
1040                    ImapConstants.UID_STORE + " %s %s" + ImapConstants.FLAGS_SILENT + " (%s)",
1041                    ImapStore.joinMessageUids(messages),
1042                    value ? "+" : "-",
1043                    allFlags));
1044
1045        } catch (IOException ioe) {
1046            throw ioExceptionHandler(mConnection, ioe);
1047        } finally {
1048            destroyResponses();
1049        }
1050    }
1051
1052    /**
1053     * Persists this folder. We will always perform the proper database operation (e.g.
1054     * 'save' or 'update'). As an optimization, if a folder has not been modified, no
1055     * database operations are performed.
1056     */
1057    void save(Context context) {
1058        final Mailbox mailbox = mMailbox;
1059        if (!mailbox.isSaved()) {
1060            mailbox.save(context);
1061            mHash = mailbox.getHashes();
1062        } else {
1063            Object[] hash = mailbox.getHashes();
1064            if (!Arrays.equals(mHash, hash)) {
1065                mailbox.update(context, mailbox.toContentValues());
1066                mHash = hash;  // Save updated hash
1067            }
1068        }
1069    }
1070
1071    /**
1072     * Selects the folder for use. Before performing any operations on this folder, it
1073     * must be selected.
1074     */
1075    private void doSelect() throws IOException, MessagingException {
1076        List<ImapResponse> responses = mConnection.executeSimpleCommand(
1077                String.format(ImapConstants.SELECT + " \"%s\"",
1078                        ImapStore.encodeFolderName(mName, mStore.mPathPrefix)));
1079
1080        // Assume the folder is opened read-write; unless we are notified otherwise
1081        mMode = OpenMode.READ_WRITE;
1082        int messageCount = -1;
1083        for (ImapResponse response : responses) {
1084            if (response.isDataResponse(1, ImapConstants.EXISTS)) {
1085                messageCount = response.getStringOrEmpty(0).getNumberOrZero();
1086            } else if (response.isOk()) {
1087                final ImapString responseCode = response.getResponseCodeOrEmpty();
1088                if (responseCode.is(ImapConstants.READ_ONLY)) {
1089                    mMode = OpenMode.READ_ONLY;
1090                } else if (responseCode.is(ImapConstants.READ_WRITE)) {
1091                    mMode = OpenMode.READ_WRITE;
1092                }
1093            } else if (response.isTagged()) { // Not OK
1094                throw new MessagingException("Can't open mailbox: "
1095                        + response.getStatusResponseTextOrEmpty());
1096            }
1097        }
1098        if (messageCount == -1) {
1099            throw new MessagingException("Did not find message count during select");
1100        }
1101        mMessageCount = messageCount;
1102        mExists = true;
1103    }
1104
1105    private void checkOpen() throws MessagingException {
1106        if (!isOpen()) {
1107            throw new MessagingException("Folder " + mName + " is not open.");
1108        }
1109    }
1110
1111    private MessagingException ioExceptionHandler(ImapConnection connection, IOException ioe) {
1112        if (Email.DEBUG) {
1113            Log.d(Logging.LOG_TAG, "IO Exception detected: ", ioe);
1114        }
1115        connection.close();
1116        if (connection == mConnection) {
1117            mConnection = null; // To prevent close() from returning the connection to the pool.
1118            close(false);
1119        }
1120        return new MessagingException("IO Error", ioe);
1121    }
1122
1123    @Override
1124    public boolean equals(Object o) {
1125        if (o instanceof ImapFolder) {
1126            return ((ImapFolder)o).mName.equals(mName);
1127        }
1128        return super.equals(o);
1129    }
1130
1131    @Override
1132    public Message createMessage(String uid) {
1133        return new ImapMessage(uid, this);
1134    }
1135}
1136