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