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