1 package com.android.exchange.service; 2 3 import android.content.ContentUris; 4 import android.content.Context; 5 import android.database.Cursor; 6 import android.net.TrafficStats; 7 import android.net.Uri; 8 import android.text.format.DateUtils; 9 import android.util.Log; 10 11 import com.android.emailcommon.TrafficFlags; 12 import com.android.emailcommon.internet.Rfc822Output; 13 import com.android.emailcommon.provider.Account; 14 import com.android.emailcommon.provider.EmailContent.Attachment; 15 import com.android.emailcommon.provider.EmailContent.Body; 16 import com.android.emailcommon.provider.EmailContent.BodyColumns; 17 import com.android.emailcommon.provider.EmailContent.MailboxColumns; 18 import com.android.emailcommon.provider.EmailContent.Message; 19 import com.android.emailcommon.provider.EmailContent.MessageColumns; 20 import com.android.emailcommon.provider.EmailContent.SyncColumns; 21 import com.android.emailcommon.provider.Mailbox; 22 import com.android.emailcommon.utility.Utility; 23 import com.android.exchange.CommandStatusException.CommandStatus; 24 import com.android.exchange.Eas; 25 import com.android.exchange.EasResponse; 26 import com.android.exchange.adapter.Parser; 27 import com.android.exchange.adapter.Parser.EmptyStreamException; 28 import com.android.exchange.adapter.Serializer; 29 import com.android.exchange.adapter.Tags; 30 import com.android.mail.utils.LogUtils; 31 32 import org.apache.http.HttpEntity; 33 import org.apache.http.HttpStatus; 34 import org.apache.http.entity.InputStreamEntity; 35 36 import java.io.ByteArrayOutputStream; 37 import java.io.File; 38 import java.io.FileInputStream; 39 import java.io.FileNotFoundException; 40 import java.io.FileOutputStream; 41 import java.io.IOException; 42 import java.io.InputStream; 43 import java.io.OutputStream; 44 import java.security.cert.CertificateException; 45 import java.util.ArrayList; 46 47 /** 48 * Performs an Exchange Outbox sync, i.e. sends all mail from the Outbox. 49 */ 50 public class EasOutboxSyncHandler extends EasServerConnection { 51 // Value for a message's server id when sending fails. 52 public static final int SEND_FAILED = 1; 53 54 // WHERE clause to query for unsent messages. 55 // TODO: Is the SEND_FAILED check actually what we want? 56 public static final String MAILBOX_KEY_AND_NOT_SEND_FAILED = 57 MessageColumns.MAILBOX_KEY + "=? and (" + SyncColumns.SERVER_ID + " is null or " + 58 SyncColumns.SERVER_ID + "!=" + SEND_FAILED + ')'; 59 60 // This needs to be long enough to send the longest reasonable message, without being so long 61 // as to effectively "hang" sending of mail. The standard 30 second timeout isn't long enough 62 // for pictures and the like. For now, we'll use 15 minutes, in the knowledge that any socket 63 // failure would probably generate an Exception before timing out anyway 64 public static final long SEND_MAIL_TIMEOUT = 15 * DateUtils.MINUTE_IN_MILLIS; 65 66 private final Mailbox mMailbox; 67 private final File mCacheDir; 68 69 public EasOutboxSyncHandler(final Context context, final Account account, 70 final Mailbox mailbox) { 71 super(context, account); 72 mMailbox = mailbox; 73 mCacheDir = context.getCacheDir(); 74 } 75 76 public void performSync() { 77 // Use SMTP flags for sending mail 78 TrafficStats.setThreadStatsTag(TrafficFlags.getSmtpFlags(mContext, mAccount)); 79 // Get a cursor to Outbox messages 80 final Cursor c = mContext.getContentResolver().query(Message.CONTENT_URI, 81 Message.CONTENT_PROJECTION, MAILBOX_KEY_AND_NOT_SEND_FAILED, 82 new String[] {Long.toString(mMailbox.mId)}, null); 83 try { 84 // Loop through the messages, sending each one 85 while (c.moveToNext()) { 86 final Message message = new Message(); 87 message.restore(c); 88 if (Utility.hasUnloadedAttachments(mContext, message.mId)) { 89 // We'll just have to wait on this... 90 continue; 91 } 92 93 // TODO: Fix -- how do we want to signal to UI that we started syncing? 94 // Note the entire callback mechanism here needs improving. 95 //sendMessageStatus(message.mId, null, EmailServiceStatus.IN_PROGRESS, 0); 96 97 if (!sendOneMessage(message, 98 SmartSendInfo.getSmartSendInfo(mContext, mAccount, message))) { 99 break; 100 } 101 } 102 } finally { 103 // TODO: Some sort of sendMessageStatus() is needed here. 104 c.close(); 105 } 106 } 107 108 /** 109 * Information needed for SmartReply/SmartForward. 110 */ 111 private static class SmartSendInfo { 112 public static final String[] BODY_SOURCE_PROJECTION = 113 new String[] {BodyColumns.SOURCE_MESSAGE_KEY}; 114 public static final String WHERE_MESSAGE_KEY = Body.MESSAGE_KEY + "=?"; 115 116 final String mItemId; 117 final String mCollectionId; 118 final boolean mIsReply; 119 final ArrayList<Attachment> mRequiredAtts; 120 121 private SmartSendInfo(final String itemId, final String collectionId, final boolean isReply, 122 final ArrayList<Attachment> requiredAtts) { 123 mItemId = itemId; 124 mCollectionId = collectionId; 125 mIsReply = isReply; 126 mRequiredAtts = requiredAtts; 127 } 128 129 public String generateSmartSendCmd() { 130 final StringBuilder sb = new StringBuilder(); 131 sb.append(isForward() ? "SmartForward" : "SmartReply"); 132 sb.append("&ItemId="); 133 sb.append(Uri.encode(mItemId, ":")); 134 sb.append("&CollectionId="); 135 sb.append(Uri.encode(mCollectionId, ":")); 136 return sb.toString(); 137 } 138 139 public boolean isForward() { 140 return !mIsReply; 141 } 142 143 /** 144 * See if a given attachment is among an array of attachments; it is if the locations of 145 * both are the same (we're looking to see if they represent the same attachment on the 146 * server. Note that an attachment that isn't on the server (e.g. an outbound attachment 147 * picked from the gallery) won't have a location, so the result will always be false. 148 * 149 * @param att the attachment to test 150 * @param atts the array of attachments to look in 151 * @return whether the test attachment is among the array of attachments 152 */ 153 private static boolean amongAttachments(final Attachment att, final Attachment[] atts) { 154 final String location = att.mLocation; 155 if (location == null) return false; 156 for (final Attachment a: atts) { 157 if (location.equals(a.mLocation)) { 158 return true; 159 } 160 } 161 return false; 162 } 163 164 /** 165 * If this message should use SmartReply or SmartForward, return an object with the data 166 * for the smart send. 167 * 168 * @param context the caller's context 169 * @param account the Account we're sending from 170 * @param message the Message being sent 171 * @return an object to support smart sending, or null if not applicable. 172 */ 173 public static SmartSendInfo getSmartSendInfo(final Context context, 174 final Account account, final Message message) { 175 final int flags = message.mFlags; 176 // We only care about the original message if we include quoted text. 177 if ((flags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) != 0) { 178 return null; 179 } 180 final boolean reply = (flags & Message.FLAG_TYPE_REPLY) != 0; 181 final boolean forward = (flags & Message.FLAG_TYPE_FORWARD) != 0; 182 // We also only care for replies or forwards. 183 if (!reply && !forward) { 184 return null; 185 } 186 // Just a sanity check here, since we assume that reply and forward are mutually 187 // exclusive throughout this class. 188 if (reply && forward) { 189 return null; 190 } 191 // If we don't support SmartForward and it's a forward, then don't proceed. 192 if (forward && (account.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) == 0) { 193 return null; 194 } 195 196 // Note: itemId and collectionId are the terms used by EAS to refer to the serverId and 197 // mailboxId of a Message 198 String itemId = null; 199 String collectionId = null; 200 201 // First, we need to get the id of the reply/forward message 202 String[] cols = Utility.getRowColumns(context, Body.CONTENT_URI, BODY_SOURCE_PROJECTION, 203 WHERE_MESSAGE_KEY, new String[] {Long.toString(message.mId)}); 204 long refId = 0; 205 // TODO: We can probably just write a smarter query to do this all at once. 206 if (cols != null && cols[0] != null) { 207 refId = Long.parseLong(cols[0]); 208 // Then, we need the serverId and mailboxKey of the message 209 cols = Utility.getRowColumns(context, Message.CONTENT_URI, refId, 210 SyncColumns.SERVER_ID, MessageColumns.MAILBOX_KEY, 211 MessageColumns.PROTOCOL_SEARCH_INFO); 212 if (cols != null) { 213 itemId = cols[0]; 214 final long boxId = Long.parseLong(cols[1]); 215 // Then, we need the serverId of the mailbox 216 cols = Utility.getRowColumns(context, Mailbox.CONTENT_URI, boxId, 217 MailboxColumns.SERVER_ID); 218 if (cols != null) { 219 collectionId = cols[0]; 220 } 221 } 222 } 223 // We need either a longId or both itemId (serverId) and collectionId (mailboxId) to 224 // process a smart reply or a smart forward 225 if (itemId != null && collectionId != null) { 226 final ArrayList<Attachment> requiredAtts; 227 if (forward) { 228 // See if we can really smart forward (all reference attachments must be sent) 229 final Attachment[] outAtts = 230 Attachment.restoreAttachmentsWithMessageId(context, message.mId); 231 final Attachment[] refAtts = 232 Attachment.restoreAttachmentsWithMessageId(context, refId); 233 for (final Attachment refAtt: refAtts) { 234 // If an original attachment isn't among what's going out, we can't be smart 235 if (!amongAttachments(refAtt, outAtts)) { 236 return null; 237 } 238 } 239 requiredAtts = new ArrayList<Attachment>(); 240 for (final Attachment outAtt: outAtts) { 241 // If an outgoing attachment isn't in original message, we must send it 242 if (!amongAttachments(outAtt, refAtts)) { 243 requiredAtts.add(outAtt); 244 } 245 } 246 } else { 247 requiredAtts = null; 248 } 249 return new SmartSendInfo(itemId, collectionId, reply, requiredAtts); 250 } 251 return null; 252 } 253 } 254 255 /** 256 * Our own HttpEntity subclass that is able to insert opaque data (in this case the MIME 257 * representation of the message body as stored in a temporary file) into the serializer stream 258 */ 259 private static class SendMailEntity extends InputStreamEntity { 260 private final FileInputStream mFileStream; 261 private final long mFileLength; 262 private final int mSendTag; 263 private final Message mMessage; 264 private final SmartSendInfo mSmartSendInfo; 265 266 public SendMailEntity(final FileInputStream instream, final long length, final int tag, 267 final Message message, final SmartSendInfo smartSendInfo) { 268 super(instream, length); 269 mFileStream = instream; 270 mFileLength = length; 271 mSendTag = tag; 272 mMessage = message; 273 mSmartSendInfo = smartSendInfo; 274 } 275 276 /** 277 * We always return -1 because we don't know the actual length of the POST data (this 278 * causes HttpClient to send the data in "chunked" mode) 279 */ 280 @Override 281 public long getContentLength() { 282 final ByteArrayOutputStream baos = new ByteArrayOutputStream(); 283 try { 284 // Calculate the overhead for the WBXML data 285 writeTo(baos, false); 286 // Return the actual size that will be sent 287 return baos.size() + mFileLength; 288 } catch (final IOException e) { 289 // Just return -1 (unknown) 290 } finally { 291 try { 292 baos.close(); 293 } catch (final IOException e) { 294 // Ignore 295 } 296 } 297 return -1; 298 } 299 300 @Override 301 public void writeTo(final OutputStream outstream) throws IOException { 302 writeTo(outstream, true); 303 } 304 305 /** 306 * Write the message to the output stream 307 * @param outstream the output stream to write 308 * @param withData whether or not the actual data is to be written; true when sending 309 * mail; false when calculating size only 310 * @throws IOException 311 */ 312 public void writeTo(final OutputStream outstream, final boolean withData) 313 throws IOException { 314 // Not sure if this is possible; the check is taken from the superclass 315 if (outstream == null) { 316 throw new IllegalArgumentException("Output stream may not be null"); 317 } 318 319 // We'll serialize directly into the output stream 320 final Serializer s = new Serializer(outstream); 321 // Send the appropriate initial tag 322 s.start(mSendTag); 323 // The Message-Id for this message (note that we cannot use the messageId stored in 324 // the message, as EAS 14 limits the length to 40 chars and we use 70+) 325 s.data(Tags.COMPOSE_CLIENT_ID, "SendMail-" + System.nanoTime()); 326 // We always save sent mail 327 s.tag(Tags.COMPOSE_SAVE_IN_SENT_ITEMS); 328 329 // If we're using smart reply/forward, we need info about the original message 330 if (mSendTag != Tags.COMPOSE_SEND_MAIL) { 331 if (mSmartSendInfo != null) { 332 s.start(Tags.COMPOSE_SOURCE); 333 // For search results, use the long id (stored in mProtocolSearchInfo); else, 334 // use folder id/item id combo 335 if (mMessage.mProtocolSearchInfo != null) { 336 s.data(Tags.COMPOSE_LONG_ID, mMessage.mProtocolSearchInfo); 337 } else { 338 s.data(Tags.COMPOSE_ITEM_ID, mSmartSendInfo.mItemId); 339 s.data(Tags.COMPOSE_FOLDER_ID, mSmartSendInfo.mCollectionId); 340 } 341 s.end(); // Tags.COMPOSE_SOURCE 342 } 343 } 344 345 // Start the MIME tag; this is followed by "opaque" data (byte array) 346 s.start(Tags.COMPOSE_MIME); 347 // Send opaque data from the file stream 348 if (withData) { 349 s.opaque(mFileStream, (int)mFileLength); 350 } else { 351 s.opaqueWithoutData((int)mFileLength); 352 } 353 // And we're done 354 s.end().end().done(); 355 } 356 } 357 358 private static class SendMailParser extends Parser { 359 private final int mStartTag; 360 private int mStatus; 361 362 public SendMailParser(final InputStream in, final int startTag) throws IOException { 363 super(in); 364 mStartTag = startTag; 365 } 366 367 public int getStatus() { 368 return mStatus; 369 } 370 371 /** 372 * The only useful info in the SendMail response is the status; we capture and save it 373 */ 374 @Override 375 public boolean parse() throws IOException { 376 if (nextTag(START_DOCUMENT) != mStartTag) { 377 throw new IOException(); 378 } 379 while (nextTag(START_DOCUMENT) != END_DOCUMENT) { 380 if (tag == Tags.COMPOSE_STATUS) { 381 mStatus = getValueInt(); 382 } else { 383 skipTag(); 384 } 385 } 386 return true; 387 } 388 } 389 390 /** 391 * Attempt to send one message. 392 * @param message The message to send. 393 * @param smartSendInfo The SmartSendInfo for this message, or null if we don't have or don't 394 * want to use smart send. 395 * @return Whether or not sending this message succeeded. 396 * TODO: Improve how we handle the types of failures. I've left the old error codes in as TODOs 397 * for future reference. 398 */ 399 private boolean sendOneMessage(final Message message, final SmartSendInfo smartSendInfo) { 400 final File tmpFile; 401 try { 402 tmpFile = File.createTempFile("eas_", "tmp", mCacheDir); 403 } catch (final IOException e) { 404 return false; // TODO: Handle SyncStatus.FAILURE_IO; 405 } 406 407 final EasResponse resp; 408 // Send behavior differs pre and post EAS14. 409 final boolean isEas14 = (Double.parseDouble(mAccount.mProtocolVersion) >= 410 Eas.SUPPORTED_PROTOCOL_EX2010_DOUBLE); 411 final int modeTag = getModeTag(isEas14, smartSendInfo); 412 try { 413 if (!writeMessageToTempFile(tmpFile, message, smartSendInfo)) { 414 return false; // TODO: Handle SyncStatus.FAILURE_IO; 415 } 416 417 final FileInputStream fileStream; 418 try { 419 fileStream = new FileInputStream(tmpFile); 420 } catch (final FileNotFoundException e) { 421 return false; // TODO: Handle SyncStatus.FAILURE_IO; 422 } 423 try { 424 425 final long fileLength = tmpFile.length(); 426 final HttpEntity entity; 427 if (isEas14) { 428 entity = new SendMailEntity(fileStream, fileLength, modeTag, message, 429 smartSendInfo); 430 } else { 431 entity = new InputStreamEntity(fileStream, fileLength); 432 } 433 434 // Create the appropriate command. 435 String cmd = "SendMail"; 436 if (smartSendInfo != null) { 437 // In EAS 14, we don't send itemId and collectionId in the command 438 if (isEas14) { 439 cmd = smartSendInfo.isForward() ? "SmartForward" : "SmartReply"; 440 } else { 441 cmd = smartSendInfo.generateSmartSendCmd(); 442 } 443 } 444 // If we're not EAS 14, add our save-in-sent setting here 445 if (!isEas14) { 446 cmd += "&SaveInSent=T"; 447 } 448 // Finally, post SendMail to the server 449 try { 450 resp = sendHttpClientPost(cmd, entity, SEND_MAIL_TIMEOUT); 451 } catch (final IOException e) { 452 return false; // TODO: Handle SyncStatus.FAILURE_IO; 453 } catch (final CertificateException e) { 454 return false; 455 } 456 457 } finally { 458 try { 459 fileStream.close(); 460 } catch (final IOException e) { 461 // TODO: Should we do anything here, or is it ok to just proceed? 462 } 463 } 464 } finally { 465 if (tmpFile.exists()) { 466 tmpFile.delete(); 467 } 468 } 469 470 try { 471 final int code = resp.getStatus(); 472 if (code == HttpStatus.SC_OK) { 473 // HTTP OK before EAS 14 is a thumbs up; in EAS 14, we've got to parse 474 // the reply 475 if (isEas14) { 476 try { 477 // Try to parse the result 478 final SendMailParser p = new SendMailParser(resp.getInputStream(), modeTag); 479 // If we get here, the SendMail failed; go figure 480 p.parse(); 481 // The parser holds the status 482 final int status = p.getStatus(); 483 if (CommandStatus.isNeedsProvisioning(status)) { 484 return false; // TODO: Handle SyncStatus.FAILURE_SECURITY; 485 } else if (status == CommandStatus.ITEM_NOT_FOUND && 486 smartSendInfo != null) { 487 // Let's retry without "smart" commands. 488 return sendOneMessage(message, null); 489 } 490 // TODO: Set syncServerId = SEND_FAILED in DB? 491 return false; // TODO: Handle SyncStatus.FAILURE_MESSAGE; 492 } catch (final EmptyStreamException e) { 493 // This is actually fine; an empty stream means SendMail succeeded 494 } catch (final IOException e) { 495 // Parsing failed in some other way. 496 return false; // TODO: Handle SyncStatus.FAILURE_IO; 497 } 498 } 499 } else if (code == HttpStatus.SC_INTERNAL_SERVER_ERROR && smartSendInfo != null) { 500 // Let's retry without "smart" commands. 501 return sendOneMessage(message, null); 502 } else { 503 if (resp.isAuthError()) { 504 LogUtils.d(LogUtils.TAG, "Got auth error from server during outbox sync"); 505 return false; // TODO: Handle SyncStatus.FAILURE_LOGIN; 506 } else if (resp.isProvisionError()) { 507 LogUtils.d(LogUtils.TAG, "Got provision error from server during outbox sync."); 508 return false; // TODO: Handle SyncStatus.FAILURE_SECURITY; 509 } else { 510 // TODO: Handle some other error 511 LogUtils.d(LogUtils.TAG, 512 "Got other HTTP error from server during outbox sync: %d", code); 513 return false; 514 } 515 } 516 } finally { 517 resp.close(); 518 } 519 520 // If we manage to get here, the message sent successfully. Hooray! 521 // Delete the sent message. 522 mContext.getContentResolver().delete( 523 ContentUris.withAppendedId(Message.CONTENT_URI, message.mId), null, null); 524 return true; 525 } 526 527 /** 528 * Writes message to the temp file. 529 * @param tmpFile The temp file to use. 530 * @param message The {@link Message} to write. 531 * @param smartSendInfo The {@link SmartSendInfo} for this message send attempt. 532 * @return Whether we could successfully write the file. 533 */ 534 private boolean writeMessageToTempFile(final File tmpFile, final Message message, 535 final SmartSendInfo smartSendInfo) { 536 final FileOutputStream fileStream; 537 try { 538 fileStream = new FileOutputStream(tmpFile); 539 } catch (final FileNotFoundException e) { 540 Log.e(LogUtils.TAG, "Failed to create message file", e); 541 return false; 542 } 543 try { 544 final boolean smartSend = smartSendInfo != null; 545 final ArrayList<Attachment> attachments = 546 smartSend ? smartSendInfo.mRequiredAtts : null; 547 Rfc822Output.writeTo(mContext, message, fileStream, smartSend, true, attachments); 548 } catch (final Exception e) { 549 Log.e(LogUtils.TAG, "Failed to write message file", e); 550 return false; 551 } finally { 552 try { 553 fileStream.close(); 554 } catch (final IOException e) { 555 // should not happen 556 Log.e(LogUtils.TAG, "Failed to close file - should not happen", e); 557 } 558 } 559 return true; 560 } 561 562 private static int getModeTag(final boolean isEas14, final SmartSendInfo smartSendInfo) { 563 if (isEas14) { 564 if (smartSendInfo == null) { 565 return Tags.COMPOSE_SEND_MAIL; 566 } else if (smartSendInfo.isForward()) { 567 return Tags.COMPOSE_SMART_FORWARD; 568 } else { 569 return Tags.COMPOSE_SMART_REPLY; 570 } 571 } 572 return 0; 573 } 574 } 575