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