1/*
2 * Copyright (C) 2009 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.emailcommon.internet;
18
19import android.content.Context;
20import android.net.Uri;
21import android.text.TextUtils;
22import android.util.Base64;
23import android.util.Base64OutputStream;
24
25import com.android.emailcommon.mail.Address;
26import com.android.emailcommon.mail.MessagingException;
27import com.android.emailcommon.provider.EmailContent.Attachment;
28import com.android.emailcommon.provider.EmailContent.Body;
29import com.android.emailcommon.provider.EmailContent.Message;
30
31import com.android.mail.utils.LogUtils;
32
33import org.apache.commons.io.IOUtils;
34
35import java.io.BufferedOutputStream;
36import java.io.ByteArrayInputStream;
37import java.io.FileNotFoundException;
38import java.io.IOException;
39import java.io.InputStream;
40import java.io.OutputStream;
41import java.io.OutputStreamWriter;
42import java.io.Writer;
43import java.text.SimpleDateFormat;
44import java.util.Arrays;
45import java.util.Date;
46import java.util.List;
47import java.util.Locale;
48import java.util.regex.Matcher;
49import java.util.regex.Pattern;
50
51/**
52 * Utility class to output RFC 822 messages from provider email messages
53 */
54public class Rfc822Output {
55    private static final String TAG = "Email";
56
57    // In MIME, en_US-like date format should be used. In other words "MMM" should be encoded to
58    // "Jan", not the other localized format like "Ene" (meaning January in locale es).
59    private static final SimpleDateFormat DATE_FORMAT =
60        new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
61
62    /** A less-than-perfect pattern to pull out <body> content */
63    private static final Pattern BODY_PATTERN = Pattern.compile(
64                "(?:<\\s*body[^>]*>)(.*)(?:<\\s*/\\s*body\\s*>)",
65                Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
66    /** Match group in {@code BODY_PATTERN} for the body HTML */
67    private static final int BODY_PATTERN_GROUP = 1;
68    /** Index of the plain text version of the message body */
69    private final static int INDEX_BODY_TEXT = 0;
70    /** Index of the HTML version of the message body */
71    private final static int INDEX_BODY_HTML = 1;
72    /** Single digit [0-9] to ensure uniqueness of the MIME boundary */
73    /*package*/ static byte sBoundaryDigit;
74
75    /**
76     * Returns just the content between the <body></body> tags. This is not perfect and breaks
77     * with malformed HTML or if there happens to be special characters in the attributes of
78     * the <body> tag (e.g. a '>' in a java script block).
79     */
80    /*package*/ static String getHtmlBody(String html) {
81        Matcher match = BODY_PATTERN.matcher(html);
82        if (match.find()) {
83            return match.group(BODY_PATTERN_GROUP);    // Found body; return
84        } else {
85            return html;              // Body not found; return the full HTML and hope for the best
86        }
87    }
88
89    /**
90     * Gets both the plain text and HTML versions of the message body.
91     */
92    /*package*/ static String[] buildBodyText(Body body, boolean useSmartReply) {
93        if (body == null) {
94            return new String[2];
95        }
96        String[] messageBody = new String[] { body.mTextContent, body.mHtmlContent };
97        if (useSmartReply && body.mQuotedTextStartPos > 0) {
98            if (messageBody[0] != null) {
99                messageBody[0] = messageBody[0].substring(0, body.mQuotedTextStartPos);
100            } else if (messageBody[1] != null) {
101                messageBody[1] = messageBody[1].substring(0, body.mQuotedTextStartPos);
102            }
103        }
104        return messageBody;
105    }
106
107    /**
108     * Write the entire message to an output stream.  This method provides buffering, so it is
109     * not necessary to pass in a buffered output stream here.
110     *
111     * @param context system context for accessing the provider
112     * @param message the message to write out
113     * @param out the output stream to write the message to
114     * @param useSmartReply whether or not quoted text is appended to a reply/forward
115     * @param sendBcc Whether to add the bcc header
116     * @param attachments list of attachments to send (or null if retrieved from the message itself)
117     */
118    public static void writeTo(Context context, Message message, OutputStream out,
119            boolean useSmartReply, boolean sendBcc, List<Attachment> attachments)
120                    throws IOException, MessagingException {
121        if (message == null) {
122            // throw something?
123            return;
124        }
125
126        OutputStream stream = new BufferedOutputStream(out, 1024);
127        Writer writer = new OutputStreamWriter(stream);
128
129        // Write the fixed headers.  Ordering is arbitrary (the legacy code iterated through a
130        // hashmap here).
131
132        String date = DATE_FORMAT.format(new Date(message.mTimeStamp));
133        writeHeader(writer, "Date", date);
134
135        writeEncodedHeader(writer, "Subject", message.mSubject);
136
137        writeHeader(writer, "Message-ID", message.mMessageId);
138
139        writeAddressHeader(writer, "From", message.mFrom);
140        writeAddressHeader(writer, "To", message.mTo);
141        writeAddressHeader(writer, "Cc", message.mCc);
142        // Address fields.  Note that we skip bcc unless the sendBcc argument is true
143        // SMTP should NOT send bcc headers, but EAS must send it!
144        if (sendBcc) {
145            writeAddressHeader(writer, "Bcc", message.mBcc);
146        }
147        writeAddressHeader(writer, "Reply-To", message.mReplyTo);
148        writeHeader(writer, "MIME-Version", "1.0");
149
150        // Analyze message and determine if we have multiparts
151        Body body = Body.restoreBodyWithMessageId(context, message.mId);
152        String[] bodyText = buildBodyText(body, useSmartReply);
153
154        // If a list of attachments hasn't been passed in, build one from the message
155        if (attachments == null) {
156            attachments =
157                    Arrays.asList(Attachment.restoreAttachmentsWithMessageId(context, message.mId));
158        }
159
160        boolean multipart = attachments.size() > 0;
161        String multipartBoundary = null;
162        String multipartType = "mixed";
163
164        // Simplified case for no multipart - just emit text and be done.
165        if (!multipart) {
166            writeTextWithHeaders(writer, stream, bodyText);
167        } else {
168            // continue with multipart headers, then into multipart body
169            multipartBoundary = getNextBoundary();
170
171            // Move to the first attachment; this must succeed because multipart is true
172            if (attachments.size() == 1) {
173                // If we've got one attachment and it's an ics "attachment", we want to send
174                // this as multipart/alternative instead of multipart/mixed
175                int flags = attachments.get(0).mFlags;
176                if ((flags & Attachment.FLAG_ICS_ALTERNATIVE_PART) != 0) {
177                    multipartType = "alternative";
178                }
179            }
180
181            writeHeader(writer, "Content-Type",
182                    "multipart/" + multipartType + "; boundary=\"" + multipartBoundary + "\"");
183            // Finish headers and prepare for body section(s)
184            writer.write("\r\n");
185
186            // first multipart element is the body
187            if (bodyText[INDEX_BODY_TEXT] != null || bodyText[INDEX_BODY_HTML] != null) {
188                writeBoundary(writer, multipartBoundary, false);
189                writeTextWithHeaders(writer, stream, bodyText);
190            }
191
192            // Write out the attachments until we run out
193            for (Attachment att: attachments) {
194                writeBoundary(writer, multipartBoundary, false);
195                writeOneAttachment(context, writer, stream, att);
196                writer.write("\r\n");
197            }
198
199            // end of multipart section
200            writeBoundary(writer, multipartBoundary, true);
201        }
202
203        writer.flush();
204        out.flush();
205    }
206
207    /**
208     * Write a single attachment and its payload
209     */
210    private static void writeOneAttachment(Context context, Writer writer, OutputStream out,
211            Attachment attachment) throws IOException, MessagingException {
212        writeHeader(writer, "Content-Type",
213                attachment.mMimeType + ";\n name=\"" + attachment.mFileName + "\"");
214        writeHeader(writer, "Content-Transfer-Encoding", "base64");
215        // Most attachments (real files) will send Content-Disposition.  The suppression option
216        // is used when sending calendar invites.
217        if ((attachment.mFlags & Attachment.FLAG_ICS_ALTERNATIVE_PART) == 0) {
218            writeHeader(writer, "Content-Disposition",
219                    "attachment;"
220                    + "\n filename=\"" + attachment.mFileName + "\";"
221                    + "\n size=" + Long.toString(attachment.mSize));
222        }
223        if (attachment.mContentId != null) {
224            writeHeader(writer, "Content-ID", attachment.mContentId);
225        }
226        writer.append("\r\n");
227
228        // Set up input stream and write it out via base64
229        InputStream inStream = null;
230        try {
231            // Use content, if provided; otherwise, use the contentUri
232            if (attachment.mContentBytes != null) {
233                inStream = new ByteArrayInputStream(attachment.mContentBytes);
234            } else {
235                // First try the cached file
236                final String cachedFile = attachment.getCachedFileUri();
237                if (!TextUtils.isEmpty(cachedFile)) {
238                    final Uri cachedFileUri = Uri.parse(cachedFile);
239                    try {
240                        inStream = context.getContentResolver().openInputStream(cachedFileUri);
241                    } catch (FileNotFoundException e) {
242                        // Couldn't open the cached file, fall back to the original content uri
243                        inStream = null;
244
245                        LogUtils.d(TAG, "Rfc822Output#writeOneAttachment(), failed to load" +
246                                "cached file, falling back to: %s", attachment.getContentUri());
247                    }
248                }
249
250                if (inStream == null) {
251                    // try to open the file
252                    final Uri fileUri = Uri.parse(attachment.getContentUri());
253                    inStream = context.getContentResolver().openInputStream(fileUri);
254                }
255            }
256            // switch to output stream for base64 text output
257            writer.flush();
258            Base64OutputStream base64Out = new Base64OutputStream(
259                out, Base64.CRLF | Base64.NO_CLOSE);
260            // copy base64 data and close up
261            IOUtils.copy(inStream, base64Out);
262            base64Out.close();
263
264            // The old Base64OutputStream wrote an extra CRLF after
265            // the output.  It's not required by the base-64 spec; not
266            // sure if it's required by RFC 822 or not.
267            out.write('\r');
268            out.write('\n');
269            out.flush();
270        }
271        catch (FileNotFoundException fnfe) {
272            // Ignore this - empty file is OK
273            LogUtils.e(TAG, fnfe, "Rfc822Output#writeOneAttachment(), FileNotFoundException" +
274                    "when sending attachment");
275        }
276        catch (IOException ioe) {
277            LogUtils.e(TAG, ioe, "Rfc822Output#writeOneAttachment(), IOException" +
278                    "when sending attachment");
279            throw new MessagingException("Invalid attachment.", ioe);
280        }
281    }
282
283    /**
284     * Write a single header with no wrapping or encoding
285     *
286     * @param writer the output writer
287     * @param name the header name
288     * @param value the header value
289     */
290    private static void writeHeader(Writer writer, String name, String value) throws IOException {
291        if (value != null && value.length() > 0) {
292            writer.append(name);
293            writer.append(": ");
294            writer.append(value);
295            writer.append("\r\n");
296        }
297    }
298
299    /**
300     * Write a single header using appropriate folding & encoding
301     *
302     * @param writer the output writer
303     * @param name the header name
304     * @param value the header value
305     */
306    private static void writeEncodedHeader(Writer writer, String name, String value)
307            throws IOException {
308        if (value != null && value.length() > 0) {
309            writer.append(name);
310            writer.append(": ");
311            writer.append(MimeUtility.foldAndEncode2(value, name.length() + 2));
312            writer.append("\r\n");
313        }
314    }
315
316    /**
317     * Unpack, encode, and fold address(es) into a header
318     *
319     * @param writer the output writer
320     * @param name the header name
321     * @param value the header value (a packed list of addresses)
322     */
323    private static void writeAddressHeader(Writer writer, String name, String value)
324            throws IOException {
325        if (value != null && value.length() > 0) {
326            writer.append(name);
327            writer.append(": ");
328            writer.append(MimeUtility.fold(Address.packedToHeader(value), name.length() + 2));
329            writer.append("\r\n");
330        }
331    }
332
333    /**
334     * Write a multipart boundary
335     *
336     * @param writer the output writer
337     * @param boundary the boundary string
338     * @param end false if inner boundary, true if final boundary
339     */
340    private static void writeBoundary(Writer writer, String boundary, boolean end)
341            throws IOException {
342        writer.append("--");
343        writer.append(boundary);
344        if (end) {
345            writer.append("--");
346        }
347        writer.append("\r\n");
348    }
349
350    /**
351     * Write the body text.
352     *
353     * Note this always uses base64, even when not required.  Slightly less efficient for
354     * US-ASCII text, but handles all formats even when non-ascii chars are involved.  A small
355     * optimization might be to prescan the string for safety and send raw if possible.
356     *
357     * @param writer the output writer
358     * @param out the output stream inside the writer (used for byte[] access)
359     * @param bodyText Plain text and HTML versions of the original text of the message
360     */
361    private static void writeTextWithHeaders(Writer writer, OutputStream out, String[] bodyText)
362            throws IOException {
363        boolean html = false;
364        String text = bodyText[INDEX_BODY_TEXT];
365        if (text == null) {
366            text = bodyText[INDEX_BODY_HTML];
367            html = true;
368        }
369        if (text == null) {
370            writer.write("\r\n");       // a truly empty message
371        } else {
372            // first multipart element is the body
373            String mimeType = "text/" + (html ? "html" : "plain");
374            writeHeader(writer, "Content-Type", mimeType + "; charset=utf-8");
375            writeHeader(writer, "Content-Transfer-Encoding", "base64");
376            writer.write("\r\n");
377            byte[] textBytes = text.getBytes("UTF-8");
378            writer.flush();
379            out.write(Base64.encode(textBytes, Base64.CRLF));
380        }
381    }
382
383    /**
384     * Returns a unique boundary string.
385     */
386    /*package*/ static String getNextBoundary() {
387        StringBuilder boundary = new StringBuilder();
388        boundary.append("--_com.android.email_").append(System.nanoTime());
389        synchronized (Rfc822Output.class) {
390            boundary = boundary.append(sBoundaryDigit);
391            sBoundaryDigit = (byte)((sBoundaryDigit + 1) % 10);
392        }
393        return boundary.toString();
394    }
395}
396