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.ContentUris;
20import android.content.Context;
21import android.database.Cursor;
22import android.net.Uri;
23import android.text.Html;
24import android.text.TextUtils;
25import android.util.Base64;
26import android.util.Base64OutputStream;
27
28import com.android.emailcommon.mail.Address;
29import com.android.emailcommon.mail.MessagingException;
30import com.android.emailcommon.provider.EmailContent.Attachment;
31import com.android.emailcommon.provider.EmailContent.Body;
32import com.android.emailcommon.provider.EmailContent.Message;
33
34import org.apache.commons.io.IOUtils;
35
36import java.io.BufferedOutputStream;
37import java.io.ByteArrayInputStream;
38import java.io.FileNotFoundException;
39import java.io.IOException;
40import java.io.InputStream;
41import java.io.OutputStream;
42import java.io.OutputStreamWriter;
43import java.io.Writer;
44import java.text.SimpleDateFormat;
45import java.util.Date;
46import java.util.Locale;
47import java.util.regex.Matcher;
48import java.util.regex.Pattern;
49
50/**
51 * Utility class to output RFC 822 messages from provider email messages
52 */
53public class Rfc822Output {
54
55    private static final Pattern PATTERN_START_OF_LINE = Pattern.compile("(?m)^");
56    private static final Pattern PATTERN_ENDLINE_CRLF = Pattern.compile("\r\n");
57
58    // In MIME, en_US-like date format should be used. In other words "MMM" should be encoded to
59    // "Jan", not the other localized format like "Ene" (meaning January in locale es).
60    private static final SimpleDateFormat DATE_FORMAT =
61        new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
62
63    private static final String WHERE_NOT_SMART_FORWARD = "(" + Attachment.FLAGS + "&" +
64        Attachment.FLAG_SMART_FORWARD + ")=0";
65
66    /** A less-than-perfect pattern to pull out <body> content */
67    private static final Pattern BODY_PATTERN = Pattern.compile(
68                "(?:<\\s*body[^>]*>)(.*)(?:<\\s*/\\s*body\\s*>)",
69                Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
70    /** Match group in {@code BODDY_PATTERN} for the body HTML */
71    private static final int BODY_PATTERN_GROUP = 1;
72    /** Pattern to find both dos and unix newlines */
73    private static final Pattern NEWLINE_PATTERN =
74        Pattern.compile("\\r?\\n");
75    /** HTML string to use when replacing text newlines */
76    private static final String NEWLINE_HTML = "<br>";
77    /** Index of the plain text version of the message body */
78    private final static int INDEX_BODY_TEXT = 0;
79    /** Index of the HTML version of the message body */
80    private final static int INDEX_BODY_HTML = 1;
81    /** Single digit [0-9] to ensure uniqueness of the MIME boundary */
82    /*package*/ static byte sBoundaryDigit;
83
84    /**
85     * Returns just the content between the <body></body> tags. This is not perfect and breaks
86     * with malformed HTML or if there happens to be special characters in the attributes of
87     * the <body> tag (e.g. a '>' in a java script block).
88     */
89    /*package*/ static String getHtmlBody(String html) {
90        Matcher match = BODY_PATTERN.matcher(html);
91        if (match.find()) {
92            return match.group(BODY_PATTERN_GROUP);    // Found body; return
93        } else {
94            return html;              // Body not found; return the full HTML and hope for the best
95        }
96    }
97
98    /**
99     * Returns an HTML encoded message alternate
100     */
101    /*package*/ static String getHtmlAlternate(Body body, boolean useSmartReply) {
102        if (body.mHtmlReply == null) {
103            return null;
104        }
105        StringBuffer altMessage = new StringBuffer();
106        String htmlContent = TextUtils.htmlEncode(body.mTextContent); // Escape HTML reserved chars
107        htmlContent = NEWLINE_PATTERN.matcher(htmlContent).replaceAll(NEWLINE_HTML);
108        altMessage.append(htmlContent);
109        if (body.mIntroText != null) {
110            String htmlIntro = TextUtils.htmlEncode(body.mIntroText);
111            htmlIntro = NEWLINE_PATTERN.matcher(htmlIntro).replaceAll(NEWLINE_HTML);
112            altMessage.append(htmlIntro);
113        }
114        if (!useSmartReply) {
115            String htmlBody = getHtmlBody(body.mHtmlReply);
116            altMessage.append(htmlBody);
117        }
118        return altMessage.toString();
119    }
120
121    /**
122     * Gets both the plain text and HTML versions of the message body.
123     */
124    /*package*/ static String[] buildBodyText(Body body, int flags, boolean useSmartReply) {
125        String[] messageBody = new String[] { null, null };
126        if (body == null) {
127            return messageBody;
128        }
129        String text = body.mTextContent;
130        boolean isReply = (flags & Message.FLAG_TYPE_REPLY) != 0;
131        boolean isForward = (flags & Message.FLAG_TYPE_FORWARD) != 0;
132        // For all forwards/replies, we add the intro text
133        if (isReply || isForward) {
134            String intro = body.mIntroText == null ? "" : body.mIntroText;
135            text += intro;
136        }
137        if (useSmartReply) {
138            // useSmartReply is set to true for use by SmartReply/SmartForward in EAS.
139            // SmartForward doesn't put a break between the original and new text, so we add an LF
140            if (isForward) {
141                text += "\n";
142            }
143        } else {
144            String quotedText = body.mTextReply;
145            // If there is no plain-text body, use de-tagified HTML as the text body
146            if (quotedText == null && body.mHtmlReply != null) {
147                quotedText = Html.fromHtml(body.mHtmlReply).toString();
148            }
149            if (quotedText != null) {
150                // fix CR-LF line endings to LF-only needed by EditText.
151                Matcher matcher = PATTERN_ENDLINE_CRLF.matcher(quotedText);
152                quotedText = matcher.replaceAll("\n");
153            }
154            if (isReply) {
155                if (quotedText != null) {
156                    Matcher matcher = PATTERN_START_OF_LINE.matcher(quotedText);
157                    text += matcher.replaceAll(">");
158                }
159            } else if (isForward) {
160                if (quotedText != null) {
161                    text += quotedText;
162                }
163            }
164        }
165        messageBody[INDEX_BODY_TEXT] = text;
166        // Exchange 2003 doesn't seem to support multipart w/SmartReply and SmartForward, so
167        // we'll skip this.  Really, it would only matter if we could compose HTML replies
168        if (!useSmartReply) {
169            messageBody[INDEX_BODY_HTML] = getHtmlAlternate(body, useSmartReply);
170        }
171        return messageBody;
172    }
173
174    /**
175     * Write the entire message to an output stream.  This method provides buffering, so it is
176     * not necessary to pass in a buffered output stream here.
177     *
178     * @param context system context for accessing the provider
179     * @param messageId the message to write out
180     * @param out the output stream to write the message to
181     * @param useSmartReply whether or not quoted text is appended to a reply/forward
182     */
183    public static void writeTo(Context context, long messageId, OutputStream out,
184            boolean useSmartReply, boolean sendBcc) throws IOException, MessagingException {
185        Message message = Message.restoreMessageWithId(context, messageId);
186        if (message == null) {
187            // throw something?
188            return;
189        }
190
191        OutputStream stream = new BufferedOutputStream(out, 1024);
192        Writer writer = new OutputStreamWriter(stream);
193
194        // Write the fixed headers.  Ordering is arbitrary (the legacy code iterated through a
195        // hashmap here).
196
197        String date = DATE_FORMAT.format(new Date(message.mTimeStamp));
198        writeHeader(writer, "Date", date);
199
200        writeEncodedHeader(writer, "Subject", message.mSubject);
201
202        writeHeader(writer, "Message-ID", message.mMessageId);
203
204        writeAddressHeader(writer, "From", message.mFrom);
205        writeAddressHeader(writer, "To", message.mTo);
206        writeAddressHeader(writer, "Cc", message.mCc);
207        // Address fields.  Note that we skip bcc unless the sendBcc argument is true
208        // SMTP should NOT send bcc headers, but EAS must send it!
209        if (sendBcc) {
210            writeAddressHeader(writer, "Bcc", message.mBcc);
211        }
212        writeAddressHeader(writer, "Reply-To", message.mReplyTo);
213        writeHeader(writer, "MIME-Version", "1.0");
214
215        // Analyze message and determine if we have multiparts
216        Body body = Body.restoreBodyWithMessageId(context, message.mId);
217        String[] bodyText = buildBodyText(body, message.mFlags, useSmartReply);
218
219        Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, messageId);
220        Cursor attachmentsCursor = context.getContentResolver().query(uri,
221                Attachment.CONTENT_PROJECTION, WHERE_NOT_SMART_FORWARD, null, null);
222
223        try {
224            int attachmentCount = attachmentsCursor.getCount();
225            boolean multipart = attachmentCount > 0;
226            String multipartBoundary = null;
227            String multipartType = "mixed";
228
229            // Simplified case for no multipart - just emit text and be done.
230            if (!multipart) {
231                writeTextWithHeaders(writer, stream, bodyText);
232            } else {
233                // continue with multipart headers, then into multipart body
234                multipartBoundary = getNextBoundary();
235
236                // Move to the first attachment; this must succeed because multipart is true
237                attachmentsCursor.moveToFirst();
238                if (attachmentCount == 1) {
239                    // If we've got one attachment and it's an ics "attachment", we want to send
240                    // this as multipart/alternative instead of multipart/mixed
241                    int flags = attachmentsCursor.getInt(Attachment.CONTENT_FLAGS_COLUMN);
242                    if ((flags & Attachment.FLAG_ICS_ALTERNATIVE_PART) != 0) {
243                        multipartType = "alternative";
244                    }
245                }
246
247                writeHeader(writer, "Content-Type",
248                        "multipart/" + multipartType + "; boundary=\"" + multipartBoundary + "\"");
249                // Finish headers and prepare for body section(s)
250                writer.write("\r\n");
251
252                // first multipart element is the body
253                if (bodyText[INDEX_BODY_TEXT] != null) {
254                    writeBoundary(writer, multipartBoundary, false);
255                    writeTextWithHeaders(writer, stream, bodyText);
256                }
257
258                // Write out the attachments until we run out
259                do {
260                    writeBoundary(writer, multipartBoundary, false);
261                    Attachment attachment =
262                        Attachment.getContent(attachmentsCursor, Attachment.class);
263                    attachment.mAccountKey = message.mAccountKey;
264                    writeOneAttachment(context, writer, stream, attachment);
265                    writer.write("\r\n");
266                } while (attachmentsCursor.moveToNext());
267
268                // end of multipart section
269                writeBoundary(writer, multipartBoundary, true);
270            }
271        } finally {
272            attachmentsCursor.close();
273        }
274
275        writer.flush();
276        out.flush();
277    }
278
279    /**
280     * Write a single attachment and its payload
281     */
282    private static void writeOneAttachment(Context context, Writer writer, OutputStream out,
283            Attachment attachment) throws IOException, MessagingException {
284        writeHeader(writer, "Content-Type",
285                attachment.mMimeType + ";\n name=\"" + attachment.mFileName + "\"");
286        writeHeader(writer, "Content-Transfer-Encoding", "base64");
287        // Most attachments (real files) will send Content-Disposition.  The suppression option
288        // is used when sending calendar invites.
289        if ((attachment.mFlags & Attachment.FLAG_ICS_ALTERNATIVE_PART) == 0) {
290            writeHeader(writer, "Content-Disposition",
291                    "attachment;"
292                    + "\n filename=\"" + attachment.mFileName + "\";"
293                    + "\n size=" + Long.toString(attachment.mSize));
294        }
295        if (attachment.mContentId != null) {
296            writeHeader(writer, "Content-ID", attachment.mContentId);
297        }
298        writer.append("\r\n");
299
300        // Set up input stream and write it out via base64
301        InputStream inStream = null;
302        try {
303            // Use content, if provided; otherwise, use the contentUri
304            if (attachment.mContentBytes != null) {
305                inStream = new ByteArrayInputStream(attachment.mContentBytes);
306            } else {
307                // try to open the file
308                Uri fileUri = Uri.parse(attachment.mContentUri);
309                inStream = context.getContentResolver().openInputStream(fileUri);
310            }
311            // switch to output stream for base64 text output
312            writer.flush();
313            Base64OutputStream base64Out = new Base64OutputStream(
314                out, Base64.CRLF | Base64.NO_CLOSE);
315            // copy base64 data and close up
316            IOUtils.copy(inStream, base64Out);
317            base64Out.close();
318
319            // The old Base64OutputStream wrote an extra CRLF after
320            // the output.  It's not required by the base-64 spec; not
321            // sure if it's required by RFC 822 or not.
322            out.write('\r');
323            out.write('\n');
324            out.flush();
325        } catch (final FileNotFoundException fnfe) {
326            // Ignore this - empty file is OK
327        } catch (final IOException ioe) {
328            throw new MessagingException("Invalid attachment.", ioe);
329        } catch (final SecurityException se) {
330            throw new MessagingException(MessagingException.GENERAL_SECURITY,
331                    "No permissions for attachment", attachment);
332        }
333    }
334
335    /**
336     * Write a single header with no wrapping or encoding
337     *
338     * @param writer the output writer
339     * @param name the header name
340     * @param value the header value
341     */
342    private static void writeHeader(Writer writer, String name, String value) throws IOException {
343        if (value != null && value.length() > 0) {
344            writer.append(name);
345            writer.append(": ");
346            writer.append(value);
347            writer.append("\r\n");
348        }
349    }
350
351    /**
352     * Write a single header using appropriate folding & encoding
353     *
354     * @param writer the output writer
355     * @param name the header name
356     * @param value the header value
357     */
358    private static void writeEncodedHeader(Writer writer, String name, String value)
359            throws IOException {
360        if (value != null && value.length() > 0) {
361            writer.append(name);
362            writer.append(": ");
363            writer.append(MimeUtility.foldAndEncode2(value, name.length() + 2));
364            writer.append("\r\n");
365        }
366    }
367
368    /**
369     * Unpack, encode, and fold address(es) into a header
370     *
371     * @param writer the output writer
372     * @param name the header name
373     * @param value the header value (a packed list of addresses)
374     */
375    private static void writeAddressHeader(Writer writer, String name, String value)
376            throws IOException {
377        if (value != null && value.length() > 0) {
378            writer.append(name);
379            writer.append(": ");
380            writer.append(MimeUtility.fold(Address.packedToHeader(value), name.length() + 2));
381            writer.append("\r\n");
382        }
383    }
384
385    /**
386     * Write a multipart boundary
387     *
388     * @param writer the output writer
389     * @param boundary the boundary string
390     * @param end false if inner boundary, true if final boundary
391     */
392    private static void writeBoundary(Writer writer, String boundary, boolean end)
393            throws IOException {
394        writer.append("--");
395        writer.append(boundary);
396        if (end) {
397            writer.append("--");
398        }
399        writer.append("\r\n");
400    }
401
402    /**
403     * Write the body text. If only one version of the body is specified (either plain text
404     * or HTML), the text is written directly. Otherwise, the plain text and HTML bodies
405     * are both written with the appropriate headers.
406     *
407     * Note this always uses base64, even when not required.  Slightly less efficient for
408     * US-ASCII text, but handles all formats even when non-ascii chars are involved.  A small
409     * optimization might be to prescan the string for safety and send raw if possible.
410     *
411     * @param writer the output writer
412     * @param out the output stream inside the writer (used for byte[] access)
413     * @param bodyText Plain text and HTML versions of the original text of the message
414     */
415    private static void writeTextWithHeaders(Writer writer, OutputStream out, String[] bodyText)
416            throws IOException {
417        String text = bodyText[INDEX_BODY_TEXT];
418        String html = bodyText[INDEX_BODY_HTML];
419
420        if (text == null) {
421            writer.write("\r\n");       // a truly empty message
422        } else {
423            String multipartBoundary = null;
424            boolean multipart = html != null;
425
426            // Simplified case for no multipart - just emit text and be done.
427            if (multipart) {
428                // continue with multipart headers, then into multipart body
429                multipartBoundary = getNextBoundary();
430
431                writeHeader(writer, "Content-Type",
432                        "multipart/alternative; boundary=\"" + multipartBoundary + "\"");
433                // Finish headers and prepare for body section(s)
434                writer.write("\r\n");
435                writeBoundary(writer, multipartBoundary, false);
436            }
437
438            // first multipart element is the body
439            writeHeader(writer, "Content-Type", "text/plain; charset=utf-8");
440            writeHeader(writer, "Content-Transfer-Encoding", "base64");
441            writer.write("\r\n");
442            byte[] textBytes = text.getBytes("UTF-8");
443            writer.flush();
444            out.write(Base64.encode(textBytes, Base64.CRLF));
445
446            if (multipart) {
447                // next multipart section
448                writeBoundary(writer, multipartBoundary, false);
449
450                writeHeader(writer, "Content-Type", "text/html; charset=utf-8");
451                writeHeader(writer, "Content-Transfer-Encoding", "base64");
452                writer.write("\r\n");
453                byte[] htmlBytes = html.getBytes("UTF-8");
454                writer.flush();
455                out.write(Base64.encode(htmlBytes, Base64.CRLF));
456
457                // end of multipart section
458                writeBoundary(writer, multipartBoundary, true);
459            }
460        }
461    }
462
463    /**
464     * Returns a unique boundary string.
465     */
466    /*package*/ static String getNextBoundary() {
467        StringBuilder boundary = new StringBuilder();
468        boundary.append("--_com.android.email_").append(System.nanoTime());
469        synchronized (Rfc822Output.class) {
470            boundary = boundary.append(sBoundaryDigit);
471            sBoundaryDigit = (byte)((sBoundaryDigit + 1) % 10);
472        }
473        return boundary.toString();
474    }
475}
476