/* * Copyright (C) 2008-2009 Marc Blank * Licensed to The Android Open Source Project. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.exchange; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.TrafficStats; import android.net.Uri; import android.os.RemoteException; import android.text.TextUtils; import com.android.emailcommon.TrafficFlags; import com.android.emailcommon.internet.Rfc822Output; import com.android.emailcommon.mail.MessagingException; import com.android.emailcommon.provider.Account; import com.android.emailcommon.provider.EmailContent.Body; import com.android.emailcommon.provider.EmailContent.BodyColumns; import com.android.emailcommon.provider.EmailContent.MailboxColumns; import com.android.emailcommon.provider.EmailContent.Message; import com.android.emailcommon.provider.EmailContent.MessageColumns; import com.android.emailcommon.provider.EmailContent.SyncColumns; import com.android.emailcommon.provider.Mailbox; import com.android.emailcommon.service.EmailServiceStatus; import com.android.emailcommon.utility.Utility; import com.android.exchange.CommandStatusException.CommandStatus; import com.android.exchange.adapter.Parser; import com.android.exchange.adapter.Parser.EmptyStreamException; import com.android.exchange.adapter.Serializer; import com.android.exchange.adapter.Tags; import org.apache.http.HttpEntity; import org.apache.http.HttpStatus; import org.apache.http.entity.InputStreamEntity; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; public class EasOutboxService extends EasSyncService { public static final int SEND_FAILED = 1; public static final String MAILBOX_KEY_AND_NOT_SEND_FAILED = MessageColumns.MAILBOX_KEY + "=? and (" + SyncColumns.SERVER_ID + " is null or " + SyncColumns.SERVER_ID + "!=" + SEND_FAILED + ')'; public static final String[] BODY_SOURCE_PROJECTION = new String[] {BodyColumns.SOURCE_MESSAGE_KEY}; public static final String WHERE_MESSAGE_KEY = Body.MESSAGE_KEY + "=?"; // This is a normal email (i.e. not one of the other types) public static final int MODE_NORMAL = 0; // This is a smart reply email public static final int MODE_SMART_REPLY = 1; // This is a smart forward email public static final int MODE_SMART_FORWARD = 2; // This needs to be long enough to send the longest reasonable message, without being so long // as to effectively "hang" sending of mail. The standard 30 second timeout isn't long enough // for pictures and the like. For now, we'll use 15 minutes, in the knowledge that any socket // failure would probably generate an Exception before timing out anyway public static final int SEND_MAIL_TIMEOUT = 15*MINUTES; public EasOutboxService(Context _context, Mailbox _mailbox) { super(_context, _mailbox); } /** * Our own HttpEntity subclass that is able to insert opaque data (in this case the MIME * representation of the message body as stored in a temporary file) into the serializer stream */ private static class SendMailEntity extends InputStreamEntity { private final Context mContext; private final FileInputStream mFileStream; private final long mFileLength; private final int mSendTag; private final Message mMessage; private static final int[] MODE_TAGS = new int[] {Tags.COMPOSE_SEND_MAIL, Tags.COMPOSE_SMART_REPLY, Tags.COMPOSE_SMART_FORWARD}; public SendMailEntity(Context context, FileInputStream instream, long length, int tag, Message message) { super(instream, length); mContext = context; mFileStream = instream; mFileLength = length; mSendTag = tag; mMessage = message; } /** * We always return -1 because we don't know the actual length of the POST data (this * causes HttpClient to send the data in "chunked" mode) */ @Override public long getContentLength() { ByteArrayOutputStream baos = new ByteArrayOutputStream(); try { // Calculate the overhead for the WBXML data writeTo(baos, false); // Return the actual size that will be sent return baos.size() + mFileLength; } catch (IOException e) { // Just return -1 (unknown) } finally { try { baos.close(); } catch (IOException e) { // Ignore } } return -1; } @Override public void writeTo(OutputStream outstream) throws IOException { writeTo(outstream, true); } /** * Write the message to the output stream * @param outstream the output stream to write * @param withData whether or not the actual data is to be written; true when sending * mail; false when calculating size only * @throws IOException */ public void writeTo(OutputStream outstream, boolean withData) throws IOException { // Not sure if this is possible; the check is taken from the superclass if (outstream == null) { throw new IllegalArgumentException("Output stream may not be null"); } // We'll serialize directly into the output stream Serializer s = new Serializer(outstream); // Send the appropriate initial tag s.start(mSendTag); // The Message-Id for this message (note that we cannot use the messageId stored in // the message, as EAS 14 limits the length to 40 chars and we use 70+) s.data(Tags.COMPOSE_CLIENT_ID, "SendMail-" + System.nanoTime()); // We always save sent mail s.tag(Tags.COMPOSE_SAVE_IN_SENT_ITEMS); // If we're using smart reply/forward, we need info about the original message if (mSendTag != Tags.COMPOSE_SEND_MAIL) { OriginalMessageInfo info = getOriginalMessageInfo(mContext, mMessage.mId); if (info != null) { s.start(Tags.COMPOSE_SOURCE); // For search results, use the long id (stored in mProtocolSearchInfo); else, // use folder id/item id combo if (mMessage.mProtocolSearchInfo != null) { s.data(Tags.COMPOSE_LONG_ID, mMessage.mProtocolSearchInfo); } else { s.data(Tags.COMPOSE_ITEM_ID, info.mItemId); s.data(Tags.COMPOSE_FOLDER_ID, info.mCollectionId); } s.end(); // Tags.COMPOSE_SOURCE } } // Start the MIME tag; this is followed by "opaque" data (byte array) s.start(Tags.COMPOSE_MIME); // Send opaque data from the file stream if (withData) { s.opaque(mFileStream, (int)mFileLength); } else { s.opaqueWithoutData((int)mFileLength); } // And we're done s.end().end().done(); } } private static class SendMailParser extends Parser { private final int mStartTag; private int mStatus; public SendMailParser(InputStream in, int startTag) throws IOException { super(in); mStartTag = startTag; } public int getStatus() { return mStatus; } /** * The only useful info in the SendMail response is the status; we capture and save it */ @Override public boolean parse() throws IOException { if (nextTag(START_DOCUMENT) != mStartTag) { throw new IOException(); } while (nextTag(START_DOCUMENT) != END_DOCUMENT) { if (tag == Tags.COMPOSE_STATUS) { mStatus = getValueInt(); } else { skipTag(); } } return true; } } /** * For OriginalMessageInfo, we use the terminology of EAS for the serverId and mailboxId of the * original message */ protected static class OriginalMessageInfo { final String mItemId; final String mCollectionId; final String mLongId; OriginalMessageInfo(String itemId, String collectionId, String longId) { mItemId = itemId; mCollectionId = collectionId; mLongId = longId; } } private void sendCallback(long msgId, String subject, int status) { try { ExchangeService.callback().sendMessageStatus(msgId, subject, status, 0); } catch (RemoteException e) { // It's all good } } /*package*/ String generateSmartSendCmd(boolean reply, OriginalMessageInfo info) { StringBuilder sb = new StringBuilder(); sb.append(reply ? "SmartReply" : "SmartForward"); if (!TextUtils.isEmpty(info.mLongId)) { sb.append("&LongId="); sb.append(Uri.encode(info.mLongId, ":")); } else { sb.append("&ItemId="); sb.append(Uri.encode(info.mItemId, ":")); sb.append("&CollectionId="); sb.append(Uri.encode(info.mCollectionId, ":")); } return sb.toString(); } /** * Get information about the original message that is referenced by the message to be sent; this * information will exist for replies and forwards * * @param context the caller's context * @param msgId the id of the message we're sending * @return a data structure with the serverId and mailboxId of the original message, or null if * either or both of those pieces of information can't be found */ private static OriginalMessageInfo getOriginalMessageInfo(Context context, long msgId) { // Note: itemId and collectionId are the terms used by EAS to refer to the serverId and // mailboxId of a Message String itemId = null; String collectionId = null; String longId = null; // First, we need to get the id of the reply/forward message String[] cols = Utility.getRowColumns(context, Body.CONTENT_URI, BODY_SOURCE_PROJECTION, WHERE_MESSAGE_KEY, new String[] {Long.toString(msgId)}); if (cols != null) { long refId = Long.parseLong(cols[0]); // Then, we need the serverId and mailboxKey of the message cols = Utility.getRowColumns(context, Message.CONTENT_URI, refId, SyncColumns.SERVER_ID, MessageColumns.MAILBOX_KEY, MessageColumns.PROTOCOL_SEARCH_INFO); if (cols != null) { itemId = cols[0]; long boxId = Long.parseLong(cols[1]); // Then, we need the serverId of the mailbox cols = Utility.getRowColumns(context, Mailbox.CONTENT_URI, boxId, MailboxColumns.SERVER_ID); if (cols != null) { collectionId = cols[0]; } } } // We need either a longId or both itemId (serverId) and collectionId (mailboxId) to process // a smart reply or a smart forward if (longId != null || (itemId != null && collectionId != null)){ return new OriginalMessageInfo(itemId, collectionId, longId); } return null; } private void sendFailed(long msgId, int result) { ContentValues cv = new ContentValues(); cv.put(SyncColumns.SERVER_ID, SEND_FAILED); Message.update(mContext, Message.CONTENT_URI, msgId, cv); sendCallback(msgId, null, result); } /** * Send a single message via EAS * Note that we mark messages SEND_FAILED when there is a permanent failure, rather than an * IOException, which is handled by ExchangeService with retries, backoffs, etc. * * @param cacheDir the cache directory for this context * @param msgId the _id of the message to send * @throws IOException */ int sendMessage(File cacheDir, long msgId) throws IOException, MessagingException { // We always return SUCCESS unless the sending error is account-specific (security or // authentication) rather than message-specific; returning anything else will terminate // the Outbox sync! Message-specific errors are marked in the messages themselves. int result = EmailServiceStatus.SUCCESS; // Say we're starting to send this message sendCallback(msgId, null, EmailServiceStatus.IN_PROGRESS); // Create a temporary file (this will hold the outgoing message in RFC822 (MIME) format) File tmpFile = File.createTempFile("eas_", "tmp", cacheDir); try { // Get the message and fail quickly if not found Message msg = Message.restoreMessageWithId(mContext, msgId); if (msg == null) return EmailServiceStatus.MESSAGE_NOT_FOUND; // See what kind of outgoing messge this is int flags = msg.mFlags; boolean reply = (flags & Message.FLAG_TYPE_REPLY) != 0; boolean forward = (flags & Message.FLAG_TYPE_FORWARD) != 0; boolean includeQuotedText = (flags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) == 0; // The reference message and mailbox are called item and collection in EAS OriginalMessageInfo referenceInfo = null; // Respect the sense of the include quoted text flag if (includeQuotedText && (reply || forward)) { referenceInfo = getOriginalMessageInfo(mContext, msgId); } // Generally, we use SmartReply/SmartForward if we've got a good reference boolean smartSend = referenceInfo != null; // But we won't use SmartForward if the account isn't set up for it (currently, we only // use SmartForward for EAS 12.0 or later to avoid creating eml files that are // potentially difficult for the recipient to handle) if (forward && ((mAccount.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) == 0)) { smartSend = false; } // Write the message to the temporary file FileOutputStream fileOutputStream = new FileOutputStream(tmpFile); Rfc822Output.writeTo(mContext, msgId, fileOutputStream, smartSend, true); fileOutputStream.close(); // Sending via EAS14 is a whole 'nother kettle of fish boolean isEas14 = (Double.parseDouble(mAccount.mProtocolVersion) >= Eas.SUPPORTED_PROTOCOL_EX2010_DOUBLE); while (true) { // Get an input stream to our temporary file and create an entity with it FileInputStream fileStream = new FileInputStream(tmpFile); long fileLength = tmpFile.length(); // The type of entity depends on whether we're using EAS 14 HttpEntity inputEntity; // For EAS 14, we need to save the wbxml tag we're using int modeTag = 0; if (isEas14) { int mode = !smartSend ? MODE_NORMAL : reply ? MODE_SMART_REPLY : MODE_SMART_FORWARD; modeTag = SendMailEntity.MODE_TAGS[mode]; inputEntity = new SendMailEntity(mContext, fileStream, fileLength, modeTag, msg); } else { inputEntity = new InputStreamEntity(fileStream, fileLength); } // Create the appropriate command and POST it to the server String cmd = "SendMail"; if (smartSend) { // In EAS 14, we don't send itemId and collectionId in the command if (isEas14) { cmd = reply ? "SmartReply" : "SmartForward"; } else { cmd = generateSmartSendCmd(reply, referenceInfo); } } // If we're not EAS 14, add our save-in-sent setting here if (!isEas14) { cmd += "&SaveInSent=T"; } userLog("Send cmd: " + cmd); // Finally, post SendMail to the server EasResponse resp = sendHttpClientPost(cmd, inputEntity, SEND_MAIL_TIMEOUT); try { fileStream.close(); int code = resp.getStatus(); if (code == HttpStatus.SC_OK) { // HTTP OK before EAS 14 is a thumbs up; in EAS 14, we've got to parse // the reply if (isEas14) { try { // Try to parse the result SendMailParser p = new SendMailParser(resp.getInputStream(), modeTag); // If we get here, the SendMail failed; go figure p.parse(); // The parser holds the status int status = p.getStatus(); userLog("SendMail error, status: " + status); if (CommandStatus.isNeedsProvisioning(status)) { result = EmailServiceStatus.SECURITY_FAILURE; } else if (status == CommandStatus.ITEM_NOT_FOUND && smartSend) { // This is the retry case for EAS 14; we'll send without "smart" // commands next time resp.close(); smartSend = false; continue; } sendFailed(msgId, result); return result; } catch (EmptyStreamException e) { // This is actually fine; an empty stream means SendMail succeeded } } // If we're here, the SendMail command succeeded userLog("Deleting message..."); // Delete the message from the Outbox and send callback mContentResolver.delete( ContentUris.withAppendedId(Message.CONTENT_URI, msgId), null, null); sendCallback(-1, msg.mSubject, EmailServiceStatus.SUCCESS); break; } else if (code == EasSyncService.INTERNAL_SERVER_ERROR_CODE && smartSend) { // This is the retry case for EAS 12.1 and below; we'll send without "smart" // commands next time resp.close(); smartSend = false; } else { userLog("Message sending failed, code: " + code); if (EasResponse.isAuthError(code)) { result = EmailServiceStatus.LOGIN_FAILED; } else if (EasResponse.isProvisionError(code)) { result = EmailServiceStatus.SECURITY_FAILURE; } sendFailed(msgId, result); break; } } finally { resp.close(); } } } catch (IOException e) { // We catch this just to send the callback sendCallback(msgId, null, EmailServiceStatus.CONNECTION_ERROR); throw e; } finally { // Clean up the temporary file if (tmpFile.exists()) { tmpFile.delete(); } } return result; } @Override public void run() { setupService(); // Use SMTP flags for sending mail TrafficStats.setThreadStatsTag(TrafficFlags.getSmtpFlags(mContext, mAccount)); File cacheDir = mContext.getCacheDir(); try { mDeviceId = ExchangeService.getDeviceId(mContext); // Get a cursor to Outbox messages Cursor c = mContext.getContentResolver().query(Message.CONTENT_URI, Message.ID_COLUMN_PROJECTION, MAILBOX_KEY_AND_NOT_SEND_FAILED, new String[] {Long.toString(mMailbox.mId)}, null); try { // Loop through the messages, sending each one while (c.moveToNext()) { long msgId = c.getLong(Message.ID_COLUMNS_ID_COLUMN); if (msgId != 0) { if (Utility.hasUnloadedAttachments(mContext, msgId)) { // We'll just have to wait on this... continue; } int result = sendMessage(cacheDir, msgId); // If there's an error, it should stop the service; we will distinguish // at least between login failures and everything else if (result == EmailServiceStatus.LOGIN_FAILED) { mExitStatus = EXIT_LOGIN_FAILURE; return; } else if (result == EmailServiceStatus.SECURITY_FAILURE) { mExitStatus = EXIT_SECURITY_FAILURE; return; } else if (result == EmailServiceStatus.REMOTE_EXCEPTION) { mExitStatus = EXIT_EXCEPTION; return; } } } } finally { c.close(); } mExitStatus = EXIT_DONE; } catch (IOException e) { mExitStatus = EXIT_IO_ERROR; } catch (Exception e) { userLog("Exception caught in EasOutboxService", e); mExitStatus = EXIT_EXCEPTION; } finally { userLog(mMailbox.mDisplayName, ": sync finished"); userLog("Outbox exited with status ", mExitStatus); ExchangeService.done(this); } } /** * Convenience method for adding a Message to an account's outbox * @param context the context of the caller * @param accountId the accountId for the sending account * @param msg the message to send */ public static void sendMessage(Context context, long accountId, Message msg) { Mailbox mailbox = Mailbox.restoreMailboxOfType(context, accountId, Mailbox.TYPE_OUTBOX); if (mailbox != null) { msg.mMailboxKey = mailbox.mId; msg.mAccountKey = accountId; msg.save(context); } } }