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        final 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        final String[] messageBody = new String[] { body.mTextContent, body.mHtmlContent };
97        final int pos = body.mQuotedTextStartPos;
98        if (useSmartReply && pos > 0) {
99            if (messageBody[0] != null) {
100                if (pos < messageBody[0].length()) {
101                    messageBody[0] = messageBody[0].substring(0, pos);
102                }
103            } else if (messageBody[1] != null) {
104                if (pos < messageBody[1].length()) {
105                    messageBody[1] = messageBody[1].substring(0, pos);
106                }
107            }
108        }
109        return messageBody;
110    }
111
112    /**
113     * Write the entire message to an output stream.  This method provides buffering, so it is
114     * not necessary to pass in a buffered output stream here.
115     *
116     * @param context system context for accessing the provider
117     * @param message the message to write out
118     * @param out the output stream to write the message to
119     * @param useSmartReply whether or not quoted text is appended to a reply/forward
120     * @param sendBcc Whether to add the bcc header
121     * @param attachments list of attachments to send (or null if retrieved from the message itself)
122     */
123    public static void writeTo(Context context, Message message, OutputStream out,
124            boolean useSmartReply, boolean sendBcc, List<Attachment> attachments)
125                    throws IOException, MessagingException {
126        if (message == null) {
127            // throw something?
128            return;
129        }
130
131        final OutputStream stream = new BufferedOutputStream(out, 1024);
132        final Writer writer = new OutputStreamWriter(stream);
133
134        // Write the fixed headers.  Ordering is arbitrary (the legacy code iterated through a
135        // hashmap here).
136
137        final String date = DATE_FORMAT.format(new Date(message.mTimeStamp));
138        writeHeader(writer, "Date", date);
139
140        writeEncodedHeader(writer, "Subject", message.mSubject);
141
142        writeHeader(writer, "Message-ID", message.mMessageId);
143
144        writeAddressHeader(writer, "From", message.mFrom);
145        writeAddressHeader(writer, "To", message.mTo);
146        writeAddressHeader(writer, "Cc", message.mCc);
147        // Address fields.  Note that we skip bcc unless the sendBcc argument is true
148        // SMTP should NOT send bcc headers, but EAS must send it!
149        if (sendBcc) {
150            writeAddressHeader(writer, "Bcc", message.mBcc);
151        }
152        writeAddressHeader(writer, "Reply-To", message.mReplyTo);
153        writeHeader(writer, "MIME-Version", "1.0");
154
155        // Analyze message and determine if we have multiparts
156        final Body body = Body.restoreBodyWithMessageId(context, message.mId);
157        final String[] bodyText = buildBodyText(body, useSmartReply);
158
159        // If a list of attachments hasn't been passed in, build one from the message
160        if (attachments == null) {
161            attachments =
162                    Arrays.asList(Attachment.restoreAttachmentsWithMessageId(context, message.mId));
163        }
164
165        final boolean multipart = attachments.size() > 0;
166
167        // Simplified case for no multipart - just emit text and be done.
168        if (!multipart) {
169            writeTextWithHeaders(writer, stream, bodyText);
170        } else {
171            // continue with multipart headers, then into multipart body
172            final String multipartBoundary = getNextBoundary();
173            String multipartType = "mixed";
174
175            // Move to the first attachment; this must succeed because multipart is true
176            if (attachments.size() == 1) {
177                // If we've got one attachment and it's an ics "attachment", we want to send
178                // this as multipart/alternative instead of multipart/mixed
179                final int flags = attachments.get(0).mFlags;
180                if ((flags & Attachment.FLAG_ICS_ALTERNATIVE_PART) != 0) {
181                    multipartType = "alternative";
182                }
183            }
184
185            writeHeader(writer, "Content-Type",
186                    "multipart/" + multipartType + "; boundary=\"" + multipartBoundary + "\"");
187            // Finish headers and prepare for body section(s)
188            writer.write("\r\n");
189
190            // first multipart element is the body
191            if (bodyText[INDEX_BODY_TEXT] != null || bodyText[INDEX_BODY_HTML] != null) {
192                writeBoundary(writer, multipartBoundary, false);
193                writeTextWithHeaders(writer, stream, bodyText);
194            }
195
196            // Write out the attachments until we run out
197            for (final Attachment att: attachments) {
198                writeBoundary(writer, multipartBoundary, false);
199                writeOneAttachment(context, writer, stream, att);
200                writer.write("\r\n");
201            }
202
203            // end of multipart section
204            writeBoundary(writer, multipartBoundary, true);
205        }
206
207        writer.flush();
208        out.flush();
209    }
210
211    /**
212     * Write a single attachment and its payload
213     */
214    private static void writeOneAttachment(Context context, Writer writer, OutputStream out,
215            Attachment attachment) throws IOException, MessagingException {
216        writeHeader(writer, "Content-Type",
217                attachment.mMimeType + ";\n name=\"" + attachment.mFileName + "\"");
218        writeHeader(writer, "Content-Transfer-Encoding", "base64");
219        // Most attachments (real files) will send Content-Disposition.  The suppression option
220        // is used when sending calendar invites.
221        if ((attachment.mFlags & Attachment.FLAG_ICS_ALTERNATIVE_PART) == 0) {
222            writeHeader(writer, "Content-Disposition",
223                    "attachment;"
224                    + "\n filename=\"" + attachment.mFileName + "\";"
225                    + "\n size=" + Long.toString(attachment.mSize));
226        }
227        if (attachment.mContentId != null) {
228            writeHeader(writer, "Content-ID", attachment.mContentId);
229        }
230        writer.append("\r\n");
231
232        // Set up input stream and write it out via base64
233        InputStream inStream = null;
234        try {
235            // Use content, if provided; otherwise, use the contentUri
236            if (attachment.mContentBytes != null) {
237                inStream = new ByteArrayInputStream(attachment.mContentBytes);
238            } else {
239                // First try the cached file
240                final String cachedFile = attachment.getCachedFileUri();
241                if (!TextUtils.isEmpty(cachedFile)) {
242                    final Uri cachedFileUri = Uri.parse(cachedFile);
243                    try {
244                        inStream = context.getContentResolver().openInputStream(cachedFileUri);
245                    } catch (FileNotFoundException e) {
246                        // Couldn't open the cached file, fall back to the original content uri
247                        inStream = null;
248
249                        LogUtils.d(TAG, "Rfc822Output#writeOneAttachment(), failed to load" +
250                                "cached file, falling back to: %s", attachment.getContentUri());
251                    }
252                }
253
254                if (inStream == null) {
255                    // try to open the file
256                    final Uri fileUri = Uri.parse(attachment.getContentUri());
257                    inStream = context.getContentResolver().openInputStream(fileUri);
258                }
259            }
260            // switch to output stream for base64 text output
261            writer.flush();
262            Base64OutputStream base64Out = new Base64OutputStream(
263                out, Base64.CRLF | Base64.NO_CLOSE);
264            // copy base64 data and close up
265            IOUtils.copy(inStream, base64Out);
266            base64Out.close();
267
268            // The old Base64OutputStream wrote an extra CRLF after
269            // the output.  It's not required by the base-64 spec; not
270            // sure if it's required by RFC 822 or not.
271            out.write('\r');
272            out.write('\n');
273            out.flush();
274        }
275        catch (FileNotFoundException fnfe) {
276            // Ignore this - empty file is OK
277            LogUtils.e(TAG, fnfe, "Rfc822Output#writeOneAttachment(), FileNotFoundException" +
278                    "when sending attachment");
279        }
280        catch (IOException ioe) {
281            LogUtils.e(TAG, ioe, "Rfc822Output#writeOneAttachment(), IOException" +
282                    "when sending attachment");
283            throw new MessagingException("Invalid attachment.", ioe);
284        }
285    }
286
287    /**
288     * Write a single header with no wrapping or encoding
289     *
290     * @param writer the output writer
291     * @param name the header name
292     * @param value the header value
293     */
294    private static void writeHeader(Writer writer, String name, String value) throws IOException {
295        if (value != null && value.length() > 0) {
296            writer.append(name);
297            writer.append(": ");
298            writer.append(value);
299            writer.append("\r\n");
300        }
301    }
302
303    /**
304     * Write a single header using appropriate folding & encoding
305     *
306     * @param writer the output writer
307     * @param name the header name
308     * @param value the header value
309     */
310    private static void writeEncodedHeader(Writer writer, String name, String value)
311            throws IOException {
312        if (value != null && value.length() > 0) {
313            writer.append(name);
314            writer.append(": ");
315            writer.append(MimeUtility.foldAndEncode2(value, name.length() + 2));
316            writer.append("\r\n");
317        }
318    }
319
320    /**
321     * Unpack, encode, and fold address(es) into a header
322     *
323     * @param writer the output writer
324     * @param name the header name
325     * @param value the header value (a packed list of addresses)
326     */
327    private static void writeAddressHeader(Writer writer, String name, String value)
328            throws IOException {
329        if (value != null && value.length() > 0) {
330            writer.append(name);
331            writer.append(": ");
332            writer.append(MimeUtility.fold(Address.reformatToHeader(value), name.length() + 2));
333            writer.append("\r\n");
334        }
335    }
336
337    /**
338     * Write a multipart boundary
339     *
340     * @param writer the output writer
341     * @param boundary the boundary string
342     * @param end false if inner boundary, true if final boundary
343     */
344    private static void writeBoundary(Writer writer, String boundary, boolean end)
345            throws IOException {
346        writer.append("--");
347        writer.append(boundary);
348        if (end) {
349            writer.append("--");
350        }
351        writer.append("\r\n");
352    }
353
354    /**
355     * Write the body text.
356     *
357     * Note this always uses base64, even when not required.  Slightly less efficient for
358     * US-ASCII text, but handles all formats even when non-ascii chars are involved.  A small
359     * optimization might be to prescan the string for safety and send raw if possible.
360     *
361     * @param writer the output writer
362     * @param out the output stream inside the writer (used for byte[] access)
363     * @param bodyText Plain text and HTML versions of the original text of the message
364     */
365    private static void writeTextWithHeaders(Writer writer, OutputStream out, String[] bodyText)
366            throws IOException {
367        boolean html = false;
368        String text = bodyText[INDEX_BODY_TEXT];
369        if (TextUtils.isEmpty(text)) {
370            text = bodyText[INDEX_BODY_HTML];
371            html = true;
372        }
373        if (TextUtils.isEmpty(text)) {
374            writer.write("\r\n");       // a truly empty message
375        } else {
376            // first multipart element is the body
377            final String mimeType = "text/" + (html ? "html" : "plain");
378            writeHeader(writer, "Content-Type", mimeType + "; charset=utf-8");
379            writeHeader(writer, "Content-Transfer-Encoding", "base64");
380            writer.write("\r\n");
381            final byte[] textBytes = text.getBytes("UTF-8");
382            writer.flush();
383            out.write(Base64.encode(textBytes, Base64.CRLF));
384        }
385    }
386
387    /**
388     * Returns a unique boundary string.
389     */
390    /*package*/ static String getNextBoundary() {
391        final StringBuilder boundary = new StringBuilder();
392        boundary.append("--_com.android.email_").append(System.nanoTime());
393        synchronized (Rfc822Output.class) {
394            boundary.append(sBoundaryDigit);
395            sBoundaryDigit = (byte)((sBoundaryDigit + 1) % 10);
396        }
397        return boundary.toString();
398    }
399}
400