| /* |
| * Copyright (C) 2009 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.emailcommon.internet; |
| |
| import android.content.ContentUris; |
| import android.content.Context; |
| import android.database.Cursor; |
| import android.net.Uri; |
| import android.text.Html; |
| import android.text.TextUtils; |
| import android.util.Base64; |
| import android.util.Base64OutputStream; |
| |
| import com.android.emailcommon.mail.Address; |
| import com.android.emailcommon.mail.MessagingException; |
| import com.android.emailcommon.provider.EmailContent.Attachment; |
| import com.android.emailcommon.provider.EmailContent.Body; |
| import com.android.emailcommon.provider.EmailContent.Message; |
| |
| import org.apache.commons.io.IOUtils; |
| |
| import java.io.BufferedOutputStream; |
| import java.io.ByteArrayInputStream; |
| import java.io.FileNotFoundException; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.io.OutputStreamWriter; |
| import java.io.Writer; |
| import java.text.SimpleDateFormat; |
| import java.util.Date; |
| import java.util.Locale; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| /** |
| * Utility class to output RFC 822 messages from provider email messages |
| */ |
| public class Rfc822Output { |
| |
| private static final Pattern PATTERN_START_OF_LINE = Pattern.compile("(?m)^"); |
| private static final Pattern PATTERN_ENDLINE_CRLF = Pattern.compile("\r\n"); |
| |
| // In MIME, en_US-like date format should be used. In other words "MMM" should be encoded to |
| // "Jan", not the other localized format like "Ene" (meaning January in locale es). |
| private static final SimpleDateFormat DATE_FORMAT = |
| new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US); |
| |
| private static final String WHERE_NOT_SMART_FORWARD = "(" + Attachment.FLAGS + "&" + |
| Attachment.FLAG_SMART_FORWARD + ")=0"; |
| |
| /** A less-than-perfect pattern to pull out <body> content */ |
| private static final Pattern BODY_PATTERN = Pattern.compile( |
| "(?:<\\s*body[^>]*>)(.*)(?:<\\s*/\\s*body\\s*>)", |
| Pattern.CASE_INSENSITIVE | Pattern.DOTALL); |
| /** Match group in {@code BODDY_PATTERN} for the body HTML */ |
| private static final int BODY_PATTERN_GROUP = 1; |
| /** Pattern to find both dos and unix newlines */ |
| private static final Pattern NEWLINE_PATTERN = |
| Pattern.compile("\\r?\\n"); |
| /** HTML string to use when replacing text newlines */ |
| private static final String NEWLINE_HTML = "<br>"; |
| /** Index of the plain text version of the message body */ |
| private final static int INDEX_BODY_TEXT = 0; |
| /** Index of the HTML version of the message body */ |
| private final static int INDEX_BODY_HTML = 1; |
| /** Single digit [0-9] to ensure uniqueness of the MIME boundary */ |
| /*package*/ static byte sBoundaryDigit; |
| |
| /** |
| * Returns just the content between the <body></body> tags. This is not perfect and breaks |
| * with malformed HTML or if there happens to be special characters in the attributes of |
| * the <body> tag (e.g. a '>' in a java script block). |
| */ |
| /*package*/ static String getHtmlBody(String html) { |
| Matcher match = BODY_PATTERN.matcher(html); |
| if (match.find()) { |
| return match.group(BODY_PATTERN_GROUP); // Found body; return |
| } else { |
| return html; // Body not found; return the full HTML and hope for the best |
| } |
| } |
| |
| /** |
| * Returns an HTML encoded message alternate |
| */ |
| /*package*/ static String getHtmlAlternate(Body body, boolean useSmartReply) { |
| if (body.mHtmlReply == null) { |
| return null; |
| } |
| StringBuffer altMessage = new StringBuffer(); |
| String htmlContent = TextUtils.htmlEncode(body.mTextContent); // Escape HTML reserved chars |
| htmlContent = NEWLINE_PATTERN.matcher(htmlContent).replaceAll(NEWLINE_HTML); |
| altMessage.append(htmlContent); |
| if (body.mIntroText != null) { |
| String htmlIntro = TextUtils.htmlEncode(body.mIntroText); |
| htmlIntro = NEWLINE_PATTERN.matcher(htmlIntro).replaceAll(NEWLINE_HTML); |
| altMessage.append(htmlIntro); |
| } |
| if (!useSmartReply) { |
| String htmlBody = getHtmlBody(body.mHtmlReply); |
| altMessage.append(htmlBody); |
| } |
| return altMessage.toString(); |
| } |
| |
| /** |
| * Gets both the plain text and HTML versions of the message body. |
| */ |
| /*package*/ static String[] buildBodyText(Body body, int flags, boolean useSmartReply) { |
| String[] messageBody = new String[] { null, null }; |
| if (body == null) { |
| return messageBody; |
| } |
| String text = body.mTextContent; |
| boolean isReply = (flags & Message.FLAG_TYPE_REPLY) != 0; |
| boolean isForward = (flags & Message.FLAG_TYPE_FORWARD) != 0; |
| // For all forwards/replies, we add the intro text |
| if (isReply || isForward) { |
| String intro = body.mIntroText == null ? "" : body.mIntroText; |
| text += intro; |
| } |
| if (useSmartReply) { |
| // useSmartReply is set to true for use by SmartReply/SmartForward in EAS. |
| // SmartForward doesn't put a break between the original and new text, so we add an LF |
| if (isForward) { |
| text += "\n"; |
| } |
| } else { |
| String quotedText = body.mTextReply; |
| // If there is no plain-text body, use de-tagified HTML as the text body |
| if (quotedText == null && body.mHtmlReply != null) { |
| quotedText = Html.fromHtml(body.mHtmlReply).toString(); |
| } |
| if (quotedText != null) { |
| // fix CR-LF line endings to LF-only needed by EditText. |
| Matcher matcher = PATTERN_ENDLINE_CRLF.matcher(quotedText); |
| quotedText = matcher.replaceAll("\n"); |
| } |
| if (isReply) { |
| if (quotedText != null) { |
| Matcher matcher = PATTERN_START_OF_LINE.matcher(quotedText); |
| text += matcher.replaceAll(">"); |
| } |
| } else if (isForward) { |
| if (quotedText != null) { |
| text += quotedText; |
| } |
| } |
| } |
| messageBody[INDEX_BODY_TEXT] = text; |
| // Exchange 2003 doesn't seem to support multipart w/SmartReply and SmartForward, so |
| // we'll skip this. Really, it would only matter if we could compose HTML replies |
| if (!useSmartReply) { |
| messageBody[INDEX_BODY_HTML] = getHtmlAlternate(body, useSmartReply); |
| } |
| return messageBody; |
| } |
| |
| /** |
| * Write the entire message to an output stream. This method provides buffering, so it is |
| * not necessary to pass in a buffered output stream here. |
| * |
| * @param context system context for accessing the provider |
| * @param messageId the message to write out |
| * @param out the output stream to write the message to |
| * @param useSmartReply whether or not quoted text is appended to a reply/forward |
| */ |
| public static void writeTo(Context context, long messageId, OutputStream out, |
| boolean useSmartReply, boolean sendBcc) throws IOException, MessagingException { |
| Message message = Message.restoreMessageWithId(context, messageId); |
| if (message == null) { |
| // throw something? |
| return; |
| } |
| |
| OutputStream stream = new BufferedOutputStream(out, 1024); |
| Writer writer = new OutputStreamWriter(stream); |
| |
| // Write the fixed headers. Ordering is arbitrary (the legacy code iterated through a |
| // hashmap here). |
| |
| String date = DATE_FORMAT.format(new Date(message.mTimeStamp)); |
| writeHeader(writer, "Date", date); |
| |
| writeEncodedHeader(writer, "Subject", message.mSubject); |
| |
| writeHeader(writer, "Message-ID", message.mMessageId); |
| |
| writeAddressHeader(writer, "From", message.mFrom); |
| writeAddressHeader(writer, "To", message.mTo); |
| writeAddressHeader(writer, "Cc", message.mCc); |
| // Address fields. Note that we skip bcc unless the sendBcc argument is true |
| // SMTP should NOT send bcc headers, but EAS must send it! |
| if (sendBcc) { |
| writeAddressHeader(writer, "Bcc", message.mBcc); |
| } |
| writeAddressHeader(writer, "Reply-To", message.mReplyTo); |
| writeHeader(writer, "MIME-Version", "1.0"); |
| |
| // Analyze message and determine if we have multiparts |
| Body body = Body.restoreBodyWithMessageId(context, message.mId); |
| String[] bodyText = buildBodyText(body, message.mFlags, useSmartReply); |
| |
| Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, messageId); |
| Cursor attachmentsCursor = context.getContentResolver().query(uri, |
| Attachment.CONTENT_PROJECTION, WHERE_NOT_SMART_FORWARD, null, null); |
| |
| try { |
| int attachmentCount = attachmentsCursor.getCount(); |
| boolean multipart = attachmentCount > 0; |
| String multipartBoundary = null; |
| String multipartType = "mixed"; |
| |
| // Simplified case for no multipart - just emit text and be done. |
| if (!multipart) { |
| writeTextWithHeaders(writer, stream, bodyText); |
| } else { |
| // continue with multipart headers, then into multipart body |
| multipartBoundary = getNextBoundary(); |
| |
| // Move to the first attachment; this must succeed because multipart is true |
| attachmentsCursor.moveToFirst(); |
| if (attachmentCount == 1) { |
| // If we've got one attachment and it's an ics "attachment", we want to send |
| // this as multipart/alternative instead of multipart/mixed |
| int flags = attachmentsCursor.getInt(Attachment.CONTENT_FLAGS_COLUMN); |
| if ((flags & Attachment.FLAG_ICS_ALTERNATIVE_PART) != 0) { |
| multipartType = "alternative"; |
| } |
| } |
| |
| writeHeader(writer, "Content-Type", |
| "multipart/" + multipartType + "; boundary=\"" + multipartBoundary + "\""); |
| // Finish headers and prepare for body section(s) |
| writer.write("\r\n"); |
| |
| // first multipart element is the body |
| if (bodyText[INDEX_BODY_TEXT] != null) { |
| writeBoundary(writer, multipartBoundary, false); |
| writeTextWithHeaders(writer, stream, bodyText); |
| } |
| |
| // Write out the attachments until we run out |
| do { |
| writeBoundary(writer, multipartBoundary, false); |
| Attachment attachment = |
| Attachment.getContent(attachmentsCursor, Attachment.class); |
| attachment.mAccountKey = message.mAccountKey; |
| writeOneAttachment(context, writer, stream, attachment); |
| writer.write("\r\n"); |
| } while (attachmentsCursor.moveToNext()); |
| |
| // end of multipart section |
| writeBoundary(writer, multipartBoundary, true); |
| } |
| } finally { |
| attachmentsCursor.close(); |
| } |
| |
| writer.flush(); |
| out.flush(); |
| } |
| |
| /** |
| * Write a single attachment and its payload |
| */ |
| private static void writeOneAttachment(Context context, Writer writer, OutputStream out, |
| Attachment attachment) throws IOException, MessagingException { |
| writeHeader(writer, "Content-Type", |
| attachment.mMimeType + ";\n name=\"" + attachment.mFileName + "\""); |
| writeHeader(writer, "Content-Transfer-Encoding", "base64"); |
| // Most attachments (real files) will send Content-Disposition. The suppression option |
| // is used when sending calendar invites. |
| if ((attachment.mFlags & Attachment.FLAG_ICS_ALTERNATIVE_PART) == 0) { |
| writeHeader(writer, "Content-Disposition", |
| "attachment;" |
| + "\n filename=\"" + attachment.mFileName + "\";" |
| + "\n size=" + Long.toString(attachment.mSize)); |
| } |
| if (attachment.mContentId != null) { |
| writeHeader(writer, "Content-ID", attachment.mContentId); |
| } |
| writer.append("\r\n"); |
| |
| // Set up input stream and write it out via base64 |
| InputStream inStream = null; |
| try { |
| // Use content, if provided; otherwise, use the contentUri |
| if (attachment.mContentBytes != null) { |
| inStream = new ByteArrayInputStream(attachment.mContentBytes); |
| } else { |
| // try to open the file |
| Uri fileUri = Uri.parse(attachment.mContentUri); |
| inStream = context.getContentResolver().openInputStream(fileUri); |
| } |
| // switch to output stream for base64 text output |
| writer.flush(); |
| Base64OutputStream base64Out = new Base64OutputStream( |
| out, Base64.CRLF | Base64.NO_CLOSE); |
| // copy base64 data and close up |
| IOUtils.copy(inStream, base64Out); |
| base64Out.close(); |
| |
| // The old Base64OutputStream wrote an extra CRLF after |
| // the output. It's not required by the base-64 spec; not |
| // sure if it's required by RFC 822 or not. |
| out.write('\r'); |
| out.write('\n'); |
| out.flush(); |
| } catch (final FileNotFoundException fnfe) { |
| // Ignore this - empty file is OK |
| } catch (final IOException ioe) { |
| throw new MessagingException("Invalid attachment.", ioe); |
| } catch (final SecurityException se) { |
| throw new MessagingException(MessagingException.GENERAL_SECURITY, |
| "No permissions for attachment", attachment); |
| } |
| } |
| |
| /** |
| * Write a single header with no wrapping or encoding |
| * |
| * @param writer the output writer |
| * @param name the header name |
| * @param value the header value |
| */ |
| private static void writeHeader(Writer writer, String name, String value) throws IOException { |
| if (value != null && value.length() > 0) { |
| writer.append(name); |
| writer.append(": "); |
| writer.append(value); |
| writer.append("\r\n"); |
| } |
| } |
| |
| /** |
| * Write a single header using appropriate folding & encoding |
| * |
| * @param writer the output writer |
| * @param name the header name |
| * @param value the header value |
| */ |
| private static void writeEncodedHeader(Writer writer, String name, String value) |
| throws IOException { |
| if (value != null && value.length() > 0) { |
| writer.append(name); |
| writer.append(": "); |
| writer.append(MimeUtility.foldAndEncode2(value, name.length() + 2)); |
| writer.append("\r\n"); |
| } |
| } |
| |
| /** |
| * Unpack, encode, and fold address(es) into a header |
| * |
| * @param writer the output writer |
| * @param name the header name |
| * @param value the header value (a packed list of addresses) |
| */ |
| private static void writeAddressHeader(Writer writer, String name, String value) |
| throws IOException { |
| if (value != null && value.length() > 0) { |
| writer.append(name); |
| writer.append(": "); |
| writer.append(MimeUtility.fold(Address.packedToHeader(value), name.length() + 2)); |
| writer.append("\r\n"); |
| } |
| } |
| |
| /** |
| * Write a multipart boundary |
| * |
| * @param writer the output writer |
| * @param boundary the boundary string |
| * @param end false if inner boundary, true if final boundary |
| */ |
| private static void writeBoundary(Writer writer, String boundary, boolean end) |
| throws IOException { |
| writer.append("--"); |
| writer.append(boundary); |
| if (end) { |
| writer.append("--"); |
| } |
| writer.append("\r\n"); |
| } |
| |
| /** |
| * Write the body text. If only one version of the body is specified (either plain text |
| * or HTML), the text is written directly. Otherwise, the plain text and HTML bodies |
| * are both written with the appropriate headers. |
| * |
| * Note this always uses base64, even when not required. Slightly less efficient for |
| * US-ASCII text, but handles all formats even when non-ascii chars are involved. A small |
| * optimization might be to prescan the string for safety and send raw if possible. |
| * |
| * @param writer the output writer |
| * @param out the output stream inside the writer (used for byte[] access) |
| * @param bodyText Plain text and HTML versions of the original text of the message |
| */ |
| private static void writeTextWithHeaders(Writer writer, OutputStream out, String[] bodyText) |
| throws IOException { |
| String text = bodyText[INDEX_BODY_TEXT]; |
| String html = bodyText[INDEX_BODY_HTML]; |
| |
| if (text == null) { |
| writer.write("\r\n"); // a truly empty message |
| } else { |
| String multipartBoundary = null; |
| boolean multipart = html != null; |
| |
| // Simplified case for no multipart - just emit text and be done. |
| if (multipart) { |
| // continue with multipart headers, then into multipart body |
| multipartBoundary = getNextBoundary(); |
| |
| writeHeader(writer, "Content-Type", |
| "multipart/alternative; boundary=\"" + multipartBoundary + "\""); |
| // Finish headers and prepare for body section(s) |
| writer.write("\r\n"); |
| writeBoundary(writer, multipartBoundary, false); |
| } |
| |
| // first multipart element is the body |
| writeHeader(writer, "Content-Type", "text/plain; charset=utf-8"); |
| writeHeader(writer, "Content-Transfer-Encoding", "base64"); |
| writer.write("\r\n"); |
| byte[] textBytes = text.getBytes("UTF-8"); |
| writer.flush(); |
| out.write(Base64.encode(textBytes, Base64.CRLF)); |
| |
| if (multipart) { |
| // next multipart section |
| writeBoundary(writer, multipartBoundary, false); |
| |
| writeHeader(writer, "Content-Type", "text/html; charset=utf-8"); |
| writeHeader(writer, "Content-Transfer-Encoding", "base64"); |
| writer.write("\r\n"); |
| byte[] htmlBytes = html.getBytes("UTF-8"); |
| writer.flush(); |
| out.write(Base64.encode(htmlBytes, Base64.CRLF)); |
| |
| // end of multipart section |
| writeBoundary(writer, multipartBoundary, true); |
| } |
| } |
| } |
| |
| /** |
| * Returns a unique boundary string. |
| */ |
| /*package*/ static String getNextBoundary() { |
| StringBuilder boundary = new StringBuilder(); |
| boundary.append("--_com.android.email_").append(System.nanoTime()); |
| synchronized (Rfc822Output.class) { |
| boundary = boundary.append(sBoundaryDigit); |
| sBoundaryDigit = (byte)((sBoundaryDigit + 1) % 10); |
| } |
| return boundary.toString(); |
| } |
| } |