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