Attach original HTML message on forward/reply

When replying or fowarding an HTML message, we now send both plain text and
HTML bodies as a multi-part mime message. We take special care to ensure the
message bodies are in their own multi-part block and do not interfere with
any additional attachments to the message.

bug 3060920

Change-Id: I2fc3cb4e1f65bcc28486a62731b44b0ee0a99719
diff --git a/emailcommon/src/com/android/emailcommon/internet/Rfc822Output.java b/emailcommon/src/com/android/emailcommon/internet/Rfc822Output.java
index 7382d30..00de126 100644
--- a/emailcommon/src/com/android/emailcommon/internet/Rfc822Output.java
+++ b/emailcommon/src/com/android/emailcommon/internet/Rfc822Output.java
@@ -28,6 +28,8 @@
 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;
 
@@ -61,15 +63,68 @@
     private static final String WHERE_NOT_SMART_FORWARD = "(" + Attachment.FLAGS + "&" +
         Attachment.FLAG_SMART_FORWARD + ")=0";
 
-    /*package*/ static String buildBodyText(Context context, Message message,
-            boolean useSmartReply) {
-        Body body = Body.restoreBodyWithMessageId(context, message.mId);
-        if (body == null) {
+    /** 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) {
+        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);
+        }
+        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;
-        int flags = message.mFlags;
         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
@@ -83,26 +138,31 @@
             if (isForward) {
                 text += "\n";
             }
-            return text;
-        }
-
-        String quotedText = body.mTextReply;
-        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 {
+            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();
             }
-        } else if (isForward) {
             if (quotedText != null) {
-                text += quotedText;
+                // 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;
+                }
             }
         }
-        return text;
+        messageBody[INDEX_BODY_TEXT] = text;
+        messageBody[INDEX_BODY_HTML] = getHtmlAlternate(body);
+        return messageBody;
     }
 
     /**
@@ -113,8 +173,6 @@
      * @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
-     *
-     * TODO alternative parts (e.g. text+html) are not supported here.
      */
     public static void writeTo(Context context, long messageId, OutputStream out,
             boolean useSmartReply, boolean sendBcc) throws IOException, MessagingException {
@@ -149,7 +207,8 @@
         writeHeader(writer, "MIME-Version", "1.0");
 
         // Analyze message and determine if we have multiparts
-        String text = buildBodyText(context, message, useSmartReply);
+        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,
@@ -163,14 +222,10 @@
 
             // Simplified case for no multipart - just emit text and be done.
             if (!multipart) {
-                if (text != null) {
-                    writeTextWithHeaders(writer, stream, text);
-                } else {
-                    writer.write("\r\n");       // a truly empty message
-                }
+                writeTextWithHeaders(writer, stream, bodyText);
             } else {
                 // continue with multipart headers, then into multipart body
-                multipartBoundary = "--_com.android.email_" + System.nanoTime();
+                multipartBoundary = getNextBoundary();
 
                 // Move to the first attachment; this must succeed because multipart is true
                 attachmentsCursor.moveToFirst();
@@ -189,9 +244,9 @@
                 writer.write("\r\n");
 
                 // first multipart element is the body
-                if (text != null) {
+                if (bodyText[INDEX_BODY_TEXT] != null) {
                     writeBoundary(writer, multipartBoundary, false);
-                    writeTextWithHeaders(writer, stream, text);
+                    writeTextWithHeaders(writer, stream, bodyText);
                 }
 
                 // Write out the attachments until we run out
@@ -230,7 +285,9 @@
                     + "\n filename=\"" + attachment.mFileName + "\";"
                     + "\n size=" + Long.toString(attachment.mSize));
         }
-        writeHeader(writer, "Content-ID", attachment.mContentId);
+        if (attachment.mContentId != null) {
+            writeHeader(writer, "Content-ID", attachment.mContentId);
+        }
         writer.append("\r\n");
 
         // Set up input stream and write it out via base64
@@ -335,7 +392,9 @@
     }
 
     /**
-     * Write text (either as main body or inside a multipart), preceded by appropriate headers.
+     * 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
@@ -343,15 +402,66 @@
      *
      * @param writer the output writer
      * @param out the output stream inside the writer (used for byte[] access)
-     * @param text The original text of the message
+     * @param bodyText Plain text and HTML versions of the original text of the message
      */
-    private static void writeTextWithHeaders(Writer writer, OutputStream out, String text)
+    private static void writeTextWithHeaders(Writer writer, OutputStream out, String[] bodyText)
             throws IOException {
-        writeHeader(writer, "Content-Type", "text/plain; charset=utf-8");
-        writeHeader(writer, "Content-Transfer-Encoding", "base64");
-        writer.write("\r\n");
-        byte[] bytes = text.getBytes("UTF-8");
-        writer.flush();
-        out.write(Base64.encode(bytes, Base64.CRLF));
+        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();
     }
 }
diff --git a/proguard.flags b/proguard.flags
index f6355d7..d1ebcb8 100644
--- a/proguard.flags
+++ b/proguard.flags
@@ -46,6 +46,11 @@
   *** setProviderContext(android.content.Context);
 }
 
+-keepclasseswithmembers class com.android.emailcommon.internet.Rfc822Output {
+  *** getHtmlBody(java.lang.String);
+  *** buildBodyText(android.content.Context, com.android.emailcommon.provider.EmailContent$Message, boolean);
+}
+
 -keepclasseswithmembers class com.android.emailcommon.mail.Address {
   <init>(java.lang.String);
   <init>(java.lang.String,java.lang.String);
@@ -60,7 +65,7 @@
 -keep class com.android.emailcommon.service.PolicySet {
   <init>(com.android.emailcommon.provider.EmailContent$Account);
   <init>(int, int, int, int, boolean, int, int, boolean);
-  *** writeAccount(...);
+  *** writeAccount(com.android.emailcommon.provider.EmailContent$Account, java.lang.String, boolean, android.content.Context);
 }
 
 -keepclasseswithmembers class com.android.email.MessagingController {
@@ -69,7 +74,7 @@
 
 -keepclasseswithmembers class com.android.emailcommon.utility.Utility {
   *** dumpCursor(android.database.Cursor);
-  *** fromUtf8(...);
+  *** fromUtf8(byte[]);
   *** isFirstUtf8Byte(byte);
   *** replaceBareLfWithCrlf(java.lang.String);
 }
@@ -185,7 +190,7 @@
 }
 
 -keep class org.apache.james.mime4j.field.Field {
-  *** getBody(...);
+  *** getBody();
 }
 
 # The following classes are used only by unit tests.
@@ -196,5 +201,8 @@
 }
 
 -keepclasseswithmembers class org.apache.commons.io.IOUtils {
-  *** toByteArray(...);
+  *** toByteArray(java.io.InputStream);
+  *** toByteArray(java.io.Reader);
+  *** toByteArray(java.io.Reader, java.lang.String);
+  *** toByteArray(java.lang.String);
 }
diff --git a/src/com/android/email/activity/MessageCompose.java b/src/com/android/email/activity/MessageCompose.java
index 2ca634c..57eb3a8 100644
--- a/src/com/android/email/activity/MessageCompose.java
+++ b/src/com/android/email/activity/MessageCompose.java
@@ -141,7 +141,6 @@
      */
     private boolean mSourceMessageProcessed = false;
 
-    private ActionBar mActionBar;
     private TextView mFromView;
     private MultiAutoCompleteTextView mToView;
     private MultiAutoCompleteTextView mCcView;
@@ -472,7 +471,6 @@
     }
 
     private void initViews() {
-        mActionBar = getActionBar();
         mFromView = (TextView)findViewById(R.id.from);
         mToView = (MultiAutoCompleteTextView)findViewById(R.id.to);
         mCcView = (MultiAutoCompleteTextView)findViewById(R.id.cc);
@@ -481,7 +479,7 @@
         mSubjectView = (EditText)findViewById(R.id.subject);
         mMessageContentView = (EditText)findViewById(R.id.message_content);
         mAttachments = (LinearLayout)findViewById(R.id.attachments);
-        mAttachmentContainer = (LinearLayout)findViewById(R.id.attachment_container);
+        mAttachmentContainer = findViewById(R.id.attachment_container);
         mQuotedTextBar = findViewById(R.id.quoted_text_bar);
         mIncludeQuotedTextCheckBox = (CheckBox) findViewById(R.id.include_quoted_text);
         mQuotedText = (WebView)findViewById(R.id.quoted_text);
@@ -814,6 +812,8 @@
     }
 
     /**
+     * Updates the given message using values from the compose UI.
+     *
      * @param message The message to be updated.
      * @param account the account (used to obtain From: address).
      * @param hasAttachments true if it has one or more attachment.
@@ -840,14 +840,11 @@
         // Use the Intent to set flags saying this message is a reply or a forward and save the
         // unique id of the source message
         if (mSource != null && mQuotedTextBar.getVisibility() == View.VISIBLE) {
-            if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)
-                    || ACTION_FORWARD.equals(mAction)) {
-                message.mSourceKey = mSource.mId;
-                // Get the body of the source message here
-                message.mHtmlReply = mSource.mHtml;
-                message.mTextReply = mSource.mText;
-            }
-
+            // If the quote bar is visible; this must either be a reply or forward
+            message.mSourceKey = mSource.mId;
+            // Get the body of the source message here
+            message.mHtmlReply = mSource.mHtml;
+            message.mTextReply = mSource.mText;
             String fromAsString = Address.unpackToString(mSource.mFrom);
             if (ACTION_FORWARD.equals(mAction)) {
                 message.mFlags |= Message.FLAG_TYPE_FORWARD;
@@ -949,7 +946,6 @@
                 // For any unloaded attachment, set the flag saying we need it loaded
                 boolean hasUnloadedAttachments = false;
                 for (Attachment attachment : attachments) {
-
                     if (attachment.mContentUri == null &&
                             ((attachment.mFlags & Attachment.FLAG_SMART_FORWARD) != 0)) {
                         attachment.mFlags |= Attachment.FLAG_DOWNLOAD_FORWARD;
@@ -1481,13 +1477,12 @@
         return URLDecoder.decode(s, "UTF-8");
     }
 
-    // used by processSourceMessage()
+    /**
+     * Displays quoted text from the original email
+     */
     private void displayQuotedText(String textBody, String htmlBody) {
-        /* Use plain-text body if available, otherwise use HTML body.
-         * This matches the desired behavior for IMAP/POP where we only send plain-text,
-         * and for EAS which sends HTML and has no plain-text body.
-         */
-        boolean plainTextFlag = textBody != null;
+        // Only use plain text if there is no HTML body
+        boolean plainTextFlag = TextUtils.isEmpty(htmlBody);
         String text = plainTextFlag ? textBody : htmlBody;
         if (text != null) {
             text = plainTextFlag ? EmailHtmlUtil.escapeCharacterToDisplay(text) : text;
diff --git a/tests/src/com/android/emailcommon/internet/Rfc822OutputTests.java b/tests/src/com/android/emailcommon/internet/Rfc822OutputTests.java
index 1d9253f..51684d5 100644
--- a/tests/src/com/android/emailcommon/internet/Rfc822OutputTests.java
+++ b/tests/src/com/android/emailcommon/internet/Rfc822OutputTests.java
@@ -22,6 +22,7 @@
 import com.android.emailcommon.mail.MessagingException;
 import com.android.emailcommon.provider.EmailContent;
 import com.android.emailcommon.provider.EmailContent.Attachment;
+import com.android.emailcommon.provider.EmailContent.Body;
 import com.android.emailcommon.provider.EmailContent.Message;
 
 import org.apache.james.mime4j.field.Field;
@@ -51,12 +52,33 @@
     private static final String RECIPIENT_TO = "recipient-to@android.com";
     private static final String RECIPIENT_CC = "recipient-cc@android.com";
     private static final String SUBJECT = "This is the subject";
-    private static final String BODY = "This is the body.  This is also the body.";
+    private static final String REPLY_TEXT_BODY = "This is the body.  This is also the body.";
+    /** HTML reply body */
+    private static final String BODY_HTML_REPLY =
+            "<a href=\"m.google.com\">This</a> is the body.<br>This is also the body.";
+    /** Text-only version of the HTML reply body */
+    private static final String BODY_TEXT_REPLY_HTML =
+        ">This is the body.\n>This is also the body.";
     private static final String TEXT = "Here is some new text.";
 
+    // Full HTML document
+    private static String HTML_FULL_BODY = "<html><head><title>MyTitle</title></head>"
+            + "<body bgcolor=\"#ffffff\" text=\"#000000\">"
+            + "<a href=\"google.com\">test1</a></body></html>";
+    private static String HTML_FULL_RESULT = "<a href=\"google.com\">test1</a>";
+    // <body/> element w/ content
+    private static String HTML_BODY_BODY =
+            "<body bgcolor=\"#ffffff\" text=\"#000000\"><a href=\"google.com\">test2</a></body>";
+    private static String HTML_BODY_RESULT = "<a href=\"google.com\">test2</a>";
+    // No <body/> tag; just content
+    private static String HTML_NO_BODY_BODY =
+            "<a href=\"google.com\">test3</a>";
+    private static String HTML_NO_BODY_RESULT = "<a href=\"google.com\">test3</a>";
+
+    private static String REPLY_INTRO_TEXT = "\n\n" + SENDER + " wrote:\n\n";
+    private static String REPLY_INTRO_HTML = "<br><br>" + SENDER + " wrote:<br><br>";
     private Context mMockContext;
     private String mForwardIntro;
-    private String mReplyIntro;
 
     public Rfc822OutputTests () {
         super(EmailProvider.class, EmailContent.AUTHORITY);
@@ -68,7 +90,6 @@
         mMockContext = getMockContext();
         mForwardIntro = mMockContext.getString(R.string.message_compose_fwd_header_fmt, SUBJECT,
                 SENDER, RECIPIENT_TO, RECIPIENT_CC);
-        mReplyIntro = mMockContext.getString(R.string.message_compose_reply_header_fmt, SENDER);
     }
 
     // TODO Create more tests here.  Specifically, we should test to make sure that forward works
@@ -77,6 +98,25 @@
     // TODO Write test that ensures that bcc is handled properly (i.e. sent/not send depending
     // on the flag passed to writeTo
 
+    private Message createTestMessage(String text, boolean save) {
+        Message message = new Message();
+        message.mText = text;
+        message.mFrom = SENDER;
+        message.mFlags = Message.FLAG_TYPE_REPLY;
+        message.mTextReply = REPLY_TEXT_BODY;
+        message.mHtmlReply = BODY_HTML_REPLY;
+        message.mIntroText = REPLY_INTRO_TEXT;
+        if (save) {
+            message.save(mMockContext);
+        }
+        return message;
+    }
+
+    private Body createTestBody(Message message) {
+        Body body = Body.restoreBodyWithMessageId(mMockContext, message.mId);
+        return body;
+    }
+
     /**
      * Test for buildBodyText().
      * Compare with expected values.
@@ -84,56 +124,72 @@
      */
     public void testBuildBodyText() {
         // Test sending a message *without* using smart reply
-        Message message1 = new Message();
-        message1.mText = "";
-        message1.mFrom = SENDER;
-        message1.mFlags = Message.FLAG_TYPE_REPLY;
-        message1.mTextReply = BODY;
-        message1.mIntroText = mReplyIntro;
-        message1.save(mMockContext);
+        Message message1 = createTestMessage("", true);
+        Body body1 = createTestBody(message1);
+        String[] bodyParts;
 
-        String body1 = Rfc822Output.buildBodyText(mMockContext, message1, false);
-        assertEquals(mReplyIntro + ">" + BODY, body1);
+        bodyParts = Rfc822Output.buildBodyText(body1, message1.mFlags, false);
+        assertEquals(REPLY_INTRO_TEXT + ">" + REPLY_TEXT_BODY, bodyParts[0]);
 
-        message1.mId = -1;
+        message1.mId = -1;        // Changing the message; need to reset the id
         message1.mText = TEXT;
         message1.save(mMockContext);
+        body1 = createTestBody(message1);
 
-        body1 = Rfc822Output.buildBodyText(mMockContext, message1, false);
-        assertEquals(TEXT + mReplyIntro + ">" + BODY, body1);
+        bodyParts = Rfc822Output.buildBodyText(body1, message1.mFlags, false);
+        assertEquals(TEXT + REPLY_INTRO_TEXT + ">" + REPLY_TEXT_BODY, bodyParts[0]);
 
-        // Save a different message with no reply body (so we reset the id)
-        message1.mId = -1;
+        // We have an HTML reply and no text reply; use the HTML reply
+        message1.mId = -1;        // Changing the message; need to reset the id
         message1.mTextReply = null;
         message1.save(mMockContext);
-        body1 = Rfc822Output.buildBodyText(mMockContext, message1, false);
-        assertEquals(TEXT + mReplyIntro, body1);
+        body1 = createTestBody(message1);
+
+        bodyParts = Rfc822Output.buildBodyText(body1, message1.mFlags, false);
+        assertEquals(TEXT + REPLY_INTRO_TEXT + BODY_TEXT_REPLY_HTML, bodyParts[0]);
+
+        // We have no HTML or text reply; use nothing
+        message1.mId = -1;        // Changing the message; need to reset the id
+        message1.mHtmlReply = null;
+        message1.save(mMockContext);
+        body1 = createTestBody(message1);
+
+        bodyParts = Rfc822Output.buildBodyText(body1, message1.mFlags, false);
+        assertEquals(TEXT + REPLY_INTRO_TEXT, bodyParts[0]);
 
         // Test sending a message *with* using smart reply
-        Message message2 = new Message();
-        message2.mText = "";
-        message2.mFrom = SENDER;
-        message2.mFlags = Message.FLAG_TYPE_REPLY;
-        message2.mTextReply = BODY;
-        message2.mIntroText = mReplyIntro;
-        message2.save(mMockContext);
+        Message message2 = createTestMessage("", true);
+        Body body2 = createTestBody(message2);
 
-        String body2 = Rfc822Output.buildBodyText(mMockContext, message2, true);
-        assertEquals(mReplyIntro, body2);
+        bodyParts = Rfc822Output.buildBodyText(body2, message2.mFlags, true);
+        assertEquals(REPLY_INTRO_TEXT, bodyParts[0]);
 
-        message2.mId = -1;
+        message2.mId = -1;        // Changing the message; need to reset the id
         message2.mText = TEXT;
         message2.save(mMockContext);
+        body2 = createTestBody(message2);
 
-        body2 = Rfc822Output.buildBodyText(mMockContext, message2, true);
-        assertEquals(TEXT + mReplyIntro, body2);
+        bodyParts = Rfc822Output.buildBodyText(body2, message2.mFlags, true);
+        assertEquals(TEXT + REPLY_INTRO_TEXT, bodyParts[0]);
 
-        // Save a different message with no reply body (so we reset the id)
-        message2.mId = -1;
+        // We have an HTML reply and no text reply; use nothing (smart reply)
+        message2.mId = -1;        // Changing the message; need to reset the id
         message2.mTextReply = null;
         message2.save(mMockContext);
-        body2 = Rfc822Output.buildBodyText(mMockContext, message2, true);
-        assertEquals(TEXT + mReplyIntro, body2);
+        body2 = createTestBody(message2);
+
+        bodyParts = Rfc822Output.buildBodyText(body2, message2.mFlags, true);
+        assertEquals(TEXT + REPLY_INTRO_TEXT, bodyParts[0]);
+
+        // We have no HTML or text reply; use nothing
+        message2.mId = -1;        // Changing the message; need to reset the id
+        message2.mTextReply = null;
+        message2.mHtmlReply = null;
+        message2.save(mMockContext);
+        body2 = createTestBody(message2);
+
+        bodyParts = Rfc822Output.buildBodyText(body2, message2.mFlags, true);
+        assertEquals(TEXT + REPLY_INTRO_TEXT, bodyParts[0]);
     }
 
     /**
@@ -148,11 +204,12 @@
         msg.mCc = RECIPIENT_CC;
         msg.mSubject = SUBJECT;
         msg.mFlags = Message.FLAG_TYPE_FORWARD;
-        msg.mTextReply = BODY;
+        msg.mTextReply = REPLY_TEXT_BODY;
         msg.mIntroText = mForwardIntro;
         msg.save(mMockContext);
-        String body = Rfc822Output.buildBodyText(mMockContext, msg, false);
-        assertEquals(TEXT + mForwardIntro + BODY, body);
+        Body body = createTestBody(msg);
+        String[] bodyParts = Rfc822Output.buildBodyText(body, msg.mFlags, false);
+        assertEquals(TEXT + mForwardIntro + REPLY_TEXT_BODY, bodyParts[0]);
     }
 
     public void testWriteToText() throws IOException, MessagingException {
@@ -270,6 +327,108 @@
     }
 
     /**
+     * Tests various types of HTML reply text -- with full <html/> tags,
+     * with just the <body/> tags and without any surrounding tags.
+     */
+    public void testGetHtmlBody() {
+        String actual;
+        actual = Rfc822Output.getHtmlBody(HTML_FULL_BODY);
+        assertEquals(HTML_FULL_RESULT, actual);
+        actual = Rfc822Output.getHtmlBody(HTML_BODY_BODY);
+        assertEquals(HTML_BODY_RESULT, actual);
+        actual = Rfc822Output.getHtmlBody(HTML_NO_BODY_BODY);
+        assertEquals(HTML_NO_BODY_RESULT, actual);
+    }
+
+    /**
+     * Tests that the entire HTML alternate string is valid for text entered by
+     * the user. We don't test all permutations of forwarded HTML here because
+     * that is verified by testGetHtmlBody().
+     */
+    public void testGetHtmlAlternate() {
+        Message message = createTestMessage(TEXT, true);
+        Body body = createTestBody(message);
+        String html;
+
+        html = Rfc822Output.getHtmlAlternate(body);
+        assertEquals(TEXT + REPLY_INTRO_HTML + BODY_HTML_REPLY, html);
+
+        // HTML special characters; dependent upon TextUtils#htmlEncode()
+        message.mId = -1;          // Changing the message; need to reset the id
+        message.mText = "<>&'\"";
+        message.save(mMockContext);
+        body = createTestBody(message);
+
+        html = Rfc822Output.getHtmlAlternate(body);
+        assertEquals("&lt;&gt;&amp;&apos;&quot;" + REPLY_INTRO_HTML + BODY_HTML_REPLY, html);
+
+        // Newlines in user text
+        message.mId = -1;          // Changing the message; need to reset the id
+        message.mText = "dos\r\nunix\nthree\r\n\n\n";
+        message.save(mMockContext);
+        body = createTestBody(message);
+
+        html = Rfc822Output.getHtmlAlternate(body);
+        assertEquals("dos<br>unix<br>three<br><br><br>" + REPLY_INTRO_HTML + BODY_HTML_REPLY, html);
+
+        // Null HTML reply
+        message.mId = -1;        // Changing the message; need to reset the id
+        message.mHtmlReply = null;
+        message.save(mMockContext);
+        body = createTestBody(message);
+
+        html = Rfc822Output.getHtmlAlternate(body);
+        assertNull(html);
+    }
+
+    /**
+     * Test the boundary digit. We modify it indirectly.
+     */
+    public void testBoundaryDigit() {
+        // Use getBoundary() to update the boundary digit
+        Rfc822Output.sBoundaryDigit = 0; // ensure it starts at a known value
+
+        Rfc822Output.getNextBoundary();
+        assertEquals(1, Rfc822Output.sBoundaryDigit);
+        Rfc822Output.getNextBoundary();
+        assertEquals(2, Rfc822Output.sBoundaryDigit);
+        Rfc822Output.getNextBoundary();
+        assertEquals(3, Rfc822Output.sBoundaryDigit);
+        Rfc822Output.getNextBoundary();
+        assertEquals(4, Rfc822Output.sBoundaryDigit);
+        Rfc822Output.getNextBoundary();
+        assertEquals(5, Rfc822Output.sBoundaryDigit);
+        Rfc822Output.getNextBoundary();
+        assertEquals(6, Rfc822Output.sBoundaryDigit);
+        Rfc822Output.getNextBoundary();
+        assertEquals(7, Rfc822Output.sBoundaryDigit);
+        Rfc822Output.getNextBoundary();
+        assertEquals(8, Rfc822Output.sBoundaryDigit);
+        Rfc822Output.getNextBoundary();
+        assertEquals(9, Rfc822Output.sBoundaryDigit);
+        Rfc822Output.getNextBoundary(); // roll over
+        assertEquals(0, Rfc822Output.sBoundaryDigit);
+    }
+
+    private final int BOUNDARY_COUNT = 12;
+    public void testGetNextBoundary() {
+        String[] resultArray = new String[BOUNDARY_COUNT];
+        for (int i = 0; i < BOUNDARY_COUNT; i++) {
+            resultArray[i] = Rfc822Output.getNextBoundary();
+        }
+        for (int i = 0; i < BOUNDARY_COUNT; i++) {
+            final String result1 = resultArray[i];
+            for (int j = 0; j < BOUNDARY_COUNT; j++) {
+                if (i == j) {
+                    continue; // Don't verify the same result
+                }
+                final String result2 = resultArray[j];
+                assertFalse(result1.equals(result2));
+            }
+        }
+    }
+
+    /**
      * Confirm that the constructed message includes "MIME-VERSION: 1.0"
      */
     private void checkMimeVersion(org.apache.james.mime4j.message.Message mimeMessage) {