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