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