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