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