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.os.RemoteException;
27import android.text.TextUtils;
28
29import com.android.emailcommon.TrafficFlags;
30import com.android.emailcommon.internet.Rfc822Output;
31import com.android.emailcommon.mail.MessagingException;
32import com.android.emailcommon.provider.Account;
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.File;
53import java.io.FileInputStream;
54import java.io.FileOutputStream;
55import java.io.IOException;
56import java.io.InputStream;
57import java.io.OutputStream;
58
59public 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}