1/* 2 * Copyright (C) 2008-2009 Marc Blank 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.exchange; 19 20import android.content.ContentUris; 21import android.content.ContentValues; 22import android.content.Context; 23import android.database.Cursor; 24import android.net.TrafficStats; 25import android.net.Uri; 26import android.text.format.DateUtils; 27 28import com.android.emailcommon.TrafficFlags; 29import com.android.emailcommon.internet.Rfc822Output; 30import com.android.emailcommon.mail.MessagingException; 31import com.android.emailcommon.provider.Account; 32import com.android.emailcommon.provider.EmailContent.Attachment; 33import com.android.emailcommon.provider.EmailContent.Body; 34import com.android.emailcommon.provider.EmailContent.BodyColumns; 35import com.android.emailcommon.provider.EmailContent.MailboxColumns; 36import com.android.emailcommon.provider.EmailContent.Message; 37import com.android.emailcommon.provider.EmailContent.MessageColumns; 38import com.android.emailcommon.provider.EmailContent.SyncColumns; 39import com.android.emailcommon.provider.Mailbox; 40import com.android.emailcommon.service.EmailServiceStatus; 41import com.android.emailcommon.utility.Utility; 42import com.android.exchange.CommandStatusException.CommandStatus; 43import com.android.exchange.adapter.Parser; 44import com.android.exchange.adapter.Parser.EmptyStreamException; 45import com.android.exchange.adapter.Serializer; 46import com.android.exchange.adapter.Tags; 47 48import org.apache.http.HttpEntity; 49import org.apache.http.HttpStatus; 50import org.apache.http.entity.InputStreamEntity; 51 52import java.io.ByteArrayOutputStream; 53import java.io.File; 54import java.io.FileInputStream; 55import java.io.FileOutputStream; 56import java.io.IOException; 57import java.io.InputStream; 58import java.io.OutputStream; 59import java.util.ArrayList; 60 61public class EasOutboxService extends EasSyncService { 62 63 public static final int SEND_FAILED = 1; 64 public static final String MAILBOX_KEY_AND_NOT_SEND_FAILED = 65 MessageColumns.MAILBOX_KEY + "=? and (" + SyncColumns.SERVER_ID + " is null or " + 66 SyncColumns.SERVER_ID + "!=" + SEND_FAILED + ')'; 67 public static final String[] BODY_SOURCE_PROJECTION = 68 new String[] {BodyColumns.SOURCE_MESSAGE_KEY}; 69 public static final String WHERE_MESSAGE_KEY = Body.MESSAGE_KEY + "=?"; 70 71 // This is a normal email (i.e. not one of the other types) 72 public static final int MODE_NORMAL = 0; 73 // This is a smart reply email 74 public static final int MODE_SMART_REPLY = 1; 75 // This is a smart forward email 76 public static final int MODE_SMART_FORWARD = 2; 77 78 // This needs to be long enough to send the longest reasonable message, without being so long 79 // as to effectively "hang" sending of mail. The standard 30 second timeout isn't long enough 80 // for pictures and the like. For now, we'll use 15 minutes, in the knowledge that any socket 81 // failure would probably generate an Exception before timing out anyway 82 public static final int SEND_MAIL_TIMEOUT = (int)(15 * DateUtils.MINUTE_IN_MILLIS); 83 84 protected EasOutboxService(Context _context, Mailbox _mailbox) { 85 super(_context, _mailbox); 86 } 87 88 /** 89 * Our own HttpEntity subclass that is able to insert opaque data (in this case the MIME 90 * representation of the message body as stored in a temporary file) into the serializer stream 91 */ 92 private static class SendMailEntity extends InputStreamEntity { 93 private final Context mContext; 94 private final FileInputStream mFileStream; 95 private final long mFileLength; 96 private final int mSendTag; 97 private final Message mMessage; 98 99 private static final int[] MODE_TAGS = new int[] {Tags.COMPOSE_SEND_MAIL, 100 Tags.COMPOSE_SMART_REPLY, Tags.COMPOSE_SMART_FORWARD}; 101 102 public SendMailEntity(Context context, FileInputStream instream, long length, int tag, 103 Message message) { 104 super(instream, length); 105 mContext = context; 106 mFileStream = instream; 107 mFileLength = length; 108 mSendTag = tag; 109 mMessage = message; 110 } 111 112 /** 113 * We always return -1 because we don't know the actual length of the POST data (this 114 * causes HttpClient to send the data in "chunked" mode) 115 */ 116 @Override 117 public long getContentLength() { 118 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 119 try { 120 // Calculate the overhead for the WBXML data 121 writeTo(baos, false); 122 // Return the actual size that will be sent 123 return baos.size() + mFileLength; 124 } catch (IOException e) { 125 // Just return -1 (unknown) 126 } finally { 127 try { 128 baos.close(); 129 } catch (IOException e) { 130 // Ignore 131 } 132 } 133 return -1; 134 } 135 136 @Override 137 public void writeTo(OutputStream outstream) throws IOException { 138 writeTo(outstream, true); 139 } 140 141 /** 142 * Write the message to the output stream 143 * @param outstream the output stream to write 144 * @param withData whether or not the actual data is to be written; true when sending 145 * mail; false when calculating size only 146 * @throws IOException 147 */ 148 public void writeTo(OutputStream outstream, boolean withData) throws IOException { 149 // Not sure if this is possible; the check is taken from the superclass 150 if (outstream == null) { 151 throw new IllegalArgumentException("Output stream may not be null"); 152 } 153 154 // We'll serialize directly into the output stream 155 Serializer s = new Serializer(outstream); 156 // Send the appropriate initial tag 157 s.start(mSendTag); 158 // The Message-Id for this message (note that we cannot use the messageId stored in 159 // the message, as EAS 14 limits the length to 40 chars and we use 70+) 160 s.data(Tags.COMPOSE_CLIENT_ID, "SendMail-" + System.nanoTime()); 161 // We always save sent mail 162 s.tag(Tags.COMPOSE_SAVE_IN_SENT_ITEMS); 163 164 // If we're using smart reply/forward, we need info about the original message 165 if (mSendTag != Tags.COMPOSE_SEND_MAIL) { 166 OriginalMessageInfo info = getOriginalMessageInfo(mContext, mMessage.mId); 167 if (info != null) { 168 s.start(Tags.COMPOSE_SOURCE); 169 // For search results, use the long id (stored in mProtocolSearchInfo); else, 170 // use folder id/item id combo 171 if (mMessage.mProtocolSearchInfo != null) { 172 s.data(Tags.COMPOSE_LONG_ID, mMessage.mProtocolSearchInfo); 173 } else { 174 s.data(Tags.COMPOSE_ITEM_ID, info.mItemId); 175 s.data(Tags.COMPOSE_FOLDER_ID, info.mCollectionId); 176 } 177 s.end(); // Tags.COMPOSE_SOURCE 178 } 179 } 180 181 // Start the MIME tag; this is followed by "opaque" data (byte array) 182 s.start(Tags.COMPOSE_MIME); 183 // Send opaque data from the file stream 184 if (withData) { 185 s.opaque(mFileStream, (int)mFileLength); 186 } else { 187 s.opaqueWithoutData((int)mFileLength); 188 } 189 // And we're done 190 s.end().end().done(); 191 } 192 } 193 194 private static class SendMailParser extends Parser { 195 private final int mStartTag; 196 private int mStatus; 197 198 public SendMailParser(InputStream in, int startTag) throws IOException { 199 super(in); 200 mStartTag = startTag; 201 } 202 203 public int getStatus() { 204 return mStatus; 205 } 206 207 /** 208 * The only useful info in the SendMail response is the status; we capture and save it 209 */ 210 @Override 211 public boolean parse() throws IOException { 212 if (nextTag(START_DOCUMENT) != mStartTag) { 213 throw new IOException(); 214 } 215 while (nextTag(START_DOCUMENT) != END_DOCUMENT) { 216 if (tag == Tags.COMPOSE_STATUS) { 217 mStatus = getValueInt(); 218 } else { 219 skipTag(); 220 } 221 } 222 return true; 223 } 224 } 225 226 /** 227 * For OriginalMessageInfo, we use the terminology of EAS for the serverId and mailboxId of the 228 * original message 229 */ 230 protected static class OriginalMessageInfo { 231 final long mRefId; 232 final String mItemId; 233 final String mCollectionId; 234 235 OriginalMessageInfo(long refId, String itemId, String collectionId) { 236 mRefId = refId; 237 mItemId = itemId; 238 mCollectionId = collectionId; 239 } 240 } 241 242 /*package*/ String generateSmartSendCmd(boolean reply, OriginalMessageInfo info) { 243 StringBuilder sb = new StringBuilder(); 244 sb.append(reply ? "SmartReply" : "SmartForward"); 245 sb.append("&ItemId="); 246 sb.append(Uri.encode(info.mItemId, ":")); 247 sb.append("&CollectionId="); 248 sb.append(Uri.encode(info.mCollectionId, ":")); 249 return sb.toString(); 250 } 251 252 /** 253 * Get information about the original message that is referenced by the message to be sent; this 254 * information will exist for replies and forwards 255 * 256 * @param context the caller's context 257 * @param msgId the id of the message we're sending 258 * @return a data structure with the serverId and mailboxId of the original message, or null if 259 * either or both of those pieces of information can't be found 260 */ 261 private static OriginalMessageInfo getOriginalMessageInfo(Context context, long msgId) { 262 // Note: itemId and collectionId are the terms used by EAS to refer to the serverId and 263 // mailboxId of a Message 264 String itemId = null; 265 String collectionId = null; 266 267 // First, we need to get the id of the reply/forward message 268 String[] cols = Utility.getRowColumns(context, Body.CONTENT_URI, 269 BODY_SOURCE_PROJECTION, WHERE_MESSAGE_KEY, 270 new String[] {Long.toString(msgId)}); 271 long refId = 0; 272 if (cols != null) { 273 refId = Long.parseLong(cols[0]); 274 // Then, we need the serverId and mailboxKey of the message 275 cols = Utility.getRowColumns(context, Message.CONTENT_URI, refId, 276 SyncColumns.SERVER_ID, MessageColumns.MAILBOX_KEY, 277 MessageColumns.PROTOCOL_SEARCH_INFO); 278 if (cols != null) { 279 itemId = cols[0]; 280 long boxId = Long.parseLong(cols[1]); 281 // Then, we need the serverId of the mailbox 282 cols = Utility.getRowColumns(context, Mailbox.CONTENT_URI, boxId, 283 MailboxColumns.SERVER_ID); 284 if (cols != null) { 285 collectionId = cols[0]; 286 } 287 } 288 } 289 // We need either a longId or both itemId (serverId) and collectionId (mailboxId) to process 290 // a smart reply or a smart forward 291 if (itemId != null && collectionId != null){ 292 return new OriginalMessageInfo(refId, itemId, collectionId); 293 } 294 return null; 295 } 296 297 private void sendFailed(long msgId, int result) { 298 ContentValues cv = new ContentValues(); 299 cv.put(SyncColumns.SERVER_ID, SEND_FAILED); 300 Message.update(mContext, Message.CONTENT_URI, msgId, cv); 301 } 302 303 /** 304 * See if a given attachment is among an array of attachments; it is if the locations of both 305 * are the same (we're looking to see if they represent the same attachment on the server. Note 306 * that an attachment that isn't on the server (e.g. an outbound attachment picked from the 307 * gallery) won't have a location, so the result will always be false 308 * 309 * @param att the attachment to test 310 * @param atts the array of attachments to look in 311 * @return whether the test attachment is among the array of attachments 312 */ 313 private static boolean amongAttachments(Attachment att, Attachment[] atts) { 314 String location = att.mLocation; 315 if (location == null) return false; 316 for (Attachment a: atts) { 317 if (location.equals(a.mLocation)) { 318 return true; 319 } 320 } 321 return false; 322 } 323 324 /** 325 * Send a single message via EAS 326 * Note that we mark messages SEND_FAILED when there is a permanent failure, rather than an 327 * IOException, which is handled by ExchangeService with retries, backoffs, etc. 328 * 329 * @param cacheDir the cache directory for this context 330 * @param msgId the _id of the message to send 331 * @throws IOException 332 */ 333 int sendMessage(File cacheDir, long msgId) throws IOException, MessagingException { 334 // We always return SUCCESS unless the sending error is account-specific (security or 335 // authentication) rather than message-specific; returning anything else will terminate 336 // the Outbox sync! Message-specific errors are marked in the messages themselves. 337 int result = EmailServiceStatus.SUCCESS; 338 // Create a temporary file (this will hold the outgoing message in RFC822 (MIME) format) 339 File tmpFile = File.createTempFile("eas_", "tmp", cacheDir); 340 try { 341 // Get the message and fail quickly if not found 342 Message msg = Message.restoreMessageWithId(mContext, msgId); 343 if (msg == null) return EmailServiceStatus.MESSAGE_NOT_FOUND; 344 345 // See what kind of outgoing messge this is 346 int flags = msg.mFlags; 347 boolean reply = (flags & Message.FLAG_TYPE_REPLY) != 0; 348 boolean forward = (flags & Message.FLAG_TYPE_FORWARD) != 0; 349 boolean includeQuotedText = (flags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) == 0; 350 351 // The reference message and mailbox are called item and collection in EAS 352 OriginalMessageInfo referenceInfo = null; 353 // Respect the sense of the include quoted text flag 354 if (includeQuotedText && (reply || forward)) { 355 referenceInfo = getOriginalMessageInfo(mContext, msgId); 356 } 357 // Generally, we use SmartReply/SmartForward if we've got a good reference 358 boolean smartSend = referenceInfo != null; 359 // But we won't use SmartForward if the account isn't set up for it (currently, we only 360 // use SmartForward for EAS 12.0 or later to avoid creating eml files that are 361 // potentially difficult for the recipient to handle) 362 if (forward && ((mAccount.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) == 0)) { 363 smartSend = false; 364 } 365 366 ArrayList<Attachment> requiredAtts = null; 367 if (smartSend && forward) { 368 // See if we can really smart forward (all reference attachments must be sent) 369 Attachment[] outAtts = 370 Attachment.restoreAttachmentsWithMessageId(mContext, msg.mId); 371 Attachment[] refAtts = 372 Attachment.restoreAttachmentsWithMessageId(mContext, referenceInfo.mRefId); 373 for (Attachment refAtt: refAtts) { 374 // If an original attachment isn't among what's going out, we can't be "smart" 375 if (!amongAttachments(refAtt, outAtts)) { 376 smartSend = false; 377 break; 378 } 379 } 380 if (smartSend) { 381 requiredAtts = new ArrayList<Attachment>(); 382 for (Attachment outAtt: outAtts) { 383 // If an outgoing attachment isn't in original message, we must send it 384 if (!amongAttachments(outAtt, refAtts)) { 385 requiredAtts.add(outAtt); 386 } 387 } 388 } 389 } 390 391 // Write the message to the temporary file 392 FileOutputStream fileOutputStream = new FileOutputStream(tmpFile); 393 // If we're using smartSend, send along our required attachments (which will be empty 394 // if the user hasn't added new ones); otherwise, null to send everything in the msg 395 Rfc822Output.writeTo(mContext, msg, fileOutputStream, smartSend, true, 396 smartSend ? requiredAtts : null); 397 fileOutputStream.close(); 398 399 // Sending via EAS14 is a whole 'nother kettle of fish 400 boolean isEas14 = (Double.parseDouble(mAccount.mProtocolVersion) >= 401 Eas.SUPPORTED_PROTOCOL_EX2010_DOUBLE); 402 403 while (true) { 404 // Get an input stream to our temporary file and create an entity with it 405 FileInputStream fileStream = new FileInputStream(tmpFile); 406 long fileLength = tmpFile.length(); 407 408 // The type of entity depends on whether we're using EAS 14 409 HttpEntity inputEntity; 410 // For EAS 14, we need to save the wbxml tag we're using 411 int modeTag = 0; 412 if (isEas14) { 413 int mode = 414 !smartSend ? MODE_NORMAL : reply ? MODE_SMART_REPLY : MODE_SMART_FORWARD; 415 modeTag = SendMailEntity.MODE_TAGS[mode]; 416 inputEntity = 417 new SendMailEntity(mContext, fileStream, fileLength, modeTag, msg); 418 } else { 419 inputEntity = new InputStreamEntity(fileStream, fileLength); 420 } 421 // Create the appropriate command and POST it to the server 422 String cmd = "SendMail"; 423 if (smartSend) { 424 // In EAS 14, we don't send itemId and collectionId in the command 425 if (isEas14) { 426 cmd = reply ? "SmartReply" : "SmartForward"; 427 } else { 428 cmd = generateSmartSendCmd(reply, referenceInfo); 429 } 430 } 431 432 // If we're not EAS 14, add our save-in-sent setting here 433 if (!isEas14) { 434 cmd += "&SaveInSent=T"; 435 } 436 userLog("Send cmd: " + cmd); 437 438 // Finally, post SendMail to the server 439 EasResponse resp = sendHttpClientPost(cmd, inputEntity, SEND_MAIL_TIMEOUT); 440 try { 441 fileStream.close(); 442 int code = resp.getStatus(); 443 if (code == HttpStatus.SC_OK) { 444 // HTTP OK before EAS 14 is a thumbs up; in EAS 14, we've got to parse 445 // the reply 446 if (isEas14) { 447 try { 448 // Try to parse the result 449 SendMailParser p = 450 new SendMailParser(resp.getInputStream(), modeTag); 451 // If we get here, the SendMail failed; go figure 452 p.parse(); 453 // The parser holds the status 454 int status = p.getStatus(); 455 userLog("SendMail error, status: " + status); 456 if (CommandStatus.isNeedsProvisioning(status)) { 457 result = EmailServiceStatus.SECURITY_FAILURE; 458 } else if (status == CommandStatus.ITEM_NOT_FOUND && smartSend) { 459 // This is the retry case for EAS 14; we'll send without "smart" 460 // commands next time 461 resp.close(); 462 smartSend = false; 463 continue; 464 } 465 sendFailed(msgId, result); 466 return result; 467 } catch (EmptyStreamException e) { 468 // This is actually fine; an empty stream means SendMail succeeded 469 } 470 } 471 472 // If we're here, the SendMail command succeeded 473 userLog("Deleting message..."); 474 // Delete the message from the Outbox and send callback 475 mContentResolver.delete( 476 ContentUris.withAppendedId(Message.CONTENT_URI, msgId), null, null); 477 break; 478 } else if (code == EasSyncService.INTERNAL_SERVER_ERROR_CODE && smartSend) { 479 // This is the retry case for EAS 12.1 and below; we'll send without "smart" 480 // commands next time 481 resp.close(); 482 smartSend = false; 483 } else { 484 userLog("Message sending failed, code: " + code); 485 if (resp.isAuthError()) { 486 result = EmailServiceStatus.LOGIN_FAILED; 487 } else if (resp.isProvisionError()) { 488 result = EmailServiceStatus.SECURITY_FAILURE; 489 } 490 sendFailed(msgId, result); 491 break; 492 } 493 } finally { 494 resp.close(); 495 } 496 } 497 } finally { 498 // Clean up the temporary file 499 if (tmpFile.exists()) { 500 tmpFile.delete(); 501 } 502 } 503 return result; 504 } 505 506 @Override 507 public void run() { 508 setupService(); 509 // Use SMTP flags for sending mail 510 TrafficStats.setThreadStatsTag(TrafficFlags.getSmtpFlags(mContext, mAccount)); 511 File cacheDir = mContext.getCacheDir(); 512 try { 513 mDeviceId = ExchangeService.getDeviceId(mContext); 514 // Get a cursor to Outbox messages 515 Cursor c = mContext.getContentResolver().query(Message.CONTENT_URI, 516 Message.ID_COLUMN_PROJECTION, MAILBOX_KEY_AND_NOT_SEND_FAILED, 517 new String[] {Long.toString(mMailbox.mId)}, null); 518 try { 519 // Loop through the messages, sending each one 520 while (c.moveToNext()) { 521 long msgId = c.getLong(Message.ID_COLUMNS_ID_COLUMN); 522 if (msgId != 0) { 523 if (Utility.hasUnloadedAttachments(mContext, msgId)) { 524 // We'll just have to wait on this... 525 continue; 526 } 527 int result = sendMessage(cacheDir, msgId); 528 // If there's an error, it should stop the service; we will distinguish 529 // at least between login failures and everything else 530 if (result == EmailServiceStatus.LOGIN_FAILED) { 531 mExitStatus = EXIT_LOGIN_FAILURE; 532 return; 533 } else if (result == EmailServiceStatus.SECURITY_FAILURE) { 534 mExitStatus = EXIT_SECURITY_FAILURE; 535 return; 536 } else if (result == EmailServiceStatus.REMOTE_EXCEPTION) { 537 mExitStatus = EXIT_EXCEPTION; 538 return; 539 } 540 } 541 } 542 } finally { 543 c.close(); 544 } 545 mExitStatus = EXIT_DONE; 546 } catch (IOException e) { 547 mExitStatus = EXIT_IO_ERROR; 548 } catch (Exception e) { 549 userLog("Exception caught in EasOutboxService", e); 550 mExitStatus = EXIT_EXCEPTION; 551 } finally { 552 userLog(mMailbox.mDisplayName, ": sync finished"); 553 userLog("Outbox exited with status ", mExitStatus); 554 ExchangeService.done(this); 555 } 556 } 557 558 /** 559 * Convenience method for adding a Message to an account's outbox 560 * @param context the context of the caller 561 * @param accountId the accountId for the sending account 562 * @param msg the message to send 563 */ 564 public static void sendMessage(Context context, long accountId, Message msg) { 565 Mailbox mailbox = Mailbox.restoreMailboxOfType(context, accountId, Mailbox.TYPE_OUTBOX); 566 if (mailbox != null) { 567 msg.mMailboxKey = mailbox.mId; 568 msg.mAccountKey = accountId; 569 msg.save(context); 570 } 571 } 572} 573