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