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