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 
17 package com.android.emailcommon.internet;
18 
19 import android.content.Context;
20 import android.net.Uri;
21 import android.text.TextUtils;
22 import android.util.Base64;
23 import android.util.Base64OutputStream;
24 
25 import com.android.emailcommon.mail.Address;
26 import com.android.emailcommon.mail.MessagingException;
27 import com.android.emailcommon.provider.EmailContent.Attachment;
28 import com.android.emailcommon.provider.EmailContent.Body;
29 import com.android.emailcommon.provider.EmailContent.Message;
30 
31 import com.android.mail.utils.LogUtils;
32 
33 import org.apache.commons.io.IOUtils;
34 
35 import java.io.BufferedOutputStream;
36 import java.io.ByteArrayInputStream;
37 import java.io.FileNotFoundException;
38 import java.io.IOException;
39 import java.io.InputStream;
40 import java.io.OutputStream;
41 import java.io.OutputStreamWriter;
42 import java.io.Writer;
43 import java.text.SimpleDateFormat;
44 import java.util.Arrays;
45 import java.util.Date;
46 import java.util.List;
47 import java.util.Locale;
48 import java.util.regex.Matcher;
49 import java.util.regex.Pattern;
50 
51 /**
52  * Utility class to output RFC 822 messages from provider email messages
53  */
54 public 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      */
getHtmlBody(String html)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      */
buildBodyText(Body body, boolean useSmartReply)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      */
writeTo(Context context, Message message, OutputStream out, boolean useSmartReply, boolean sendBcc, List<Attachment> attachments)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      */
writeOneAttachment(Context context, Writer writer, OutputStream out, Attachment attachment)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      */
writeHeader(Writer writer, String name, String value)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      */
writeEncodedHeader(Writer writer, String name, String value)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      */
writeAddressHeader(Writer writer, String name, String value)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      */
writeBoundary(Writer writer, String boundary, boolean end)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      */
writeTextWithHeaders(Writer writer, OutputStream out, String[] bodyText)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      */
getNextBoundary()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