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