Merge "Add context to IntelliJ documentation"
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUCK
index 5c3cd07..c642154 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUCK
@@ -4,4 +4,7 @@
   group = 'server_mail',
   srcs = glob(['*IT.java']),
   labels = ['server'],
+  deps = [
+    '//lib/joda:joda-time',
+  ],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD
index 77e6a0f..2f70adc 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/BUILD
@@ -6,6 +6,7 @@
     labels = ["server"],
     deps = [
         "//lib/greenmail",
+        "//lib/joda:joda-time",
         "//lib/mail",
     ],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java
index e84af74..8d93bbf 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java
@@ -83,14 +83,14 @@
     user.deliver(createSimpleMessage());
     assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(1);
     // Let Gerrit handle emails
-    mailReceiver.handleEmails();
+    mailReceiver.handleEmails(false);
     // Check that the message is still present
     assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(1);
     // Mark the message for deletion
     mailReceiver.requestDeletion(
         mockPop3Server.getReceivedMessages()[0].getMessageID());
     // Let Gerrit handle emails
-    mailReceiver.handleEmails();
+    mailReceiver.handleEmails(false);
     // Check that the message was deleted
     assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(0);
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
new file mode 100644
index 0000000..0f6f3db
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -0,0 +1,305 @@
+// Copyright (C) 2016 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.google.gerrit.acceptance.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.MailUtil;
+import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.server.mail.receive.MailProcessor;
+import com.google.inject.Inject;
+
+import org.joda.time.DateTime;
+import org.junit.Test;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+
+public class MailProcessorIT extends AbstractDaemonTest {
+  @Inject
+  private MailProcessor mailProcessor;
+
+  @Test
+  public void parseAndPersistChangeMessage() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId)
+        .current().commentsAsList();
+    String ts = MailUtil.rfcDateformatter.format(ZonedDateTime.ofInstant(
+        comments.get(0).updated.toInstant(),
+        ZoneId.of("UTC")));
+
+    // Build Message
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt = newPlaintextBody(canonicalWebUrl.get() + "#/c/" +
+        changeInfo._number + "/1", "Test Message", null, null, null);
+    b.textContent(txt + textFooterForChange(changeId, ts));
+
+    mailProcessor.process(b.build());
+
+    Collection<ChangeMessageInfo> messages =
+        gApi.changes().id(changeId).get().messages;
+    assertThat(messages).hasSize(3);
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo("Patch Set 1:\nTest Message");
+    assertThat(Iterables.getLast(messages).tag)
+        .isEqualTo("mailMessageId=some id");
+  }
+
+  @Test
+  public void parseAndPersistInlineComment() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId)
+        .current().commentsAsList();
+    String ts = MailUtil.rfcDateformatter.format(ZonedDateTime.ofInstant(
+        comments.get(0).updated.toInstant(),
+        ZoneId.of("UTC")));
+
+    // Build Message
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt = newPlaintextBody(canonicalWebUrl.get() + "#/c/" +
+        changeInfo._number + "/1", null, "Some Inline Comment", null, null);
+    b.textContent(txt + textFooterForChange(changeId, ts));
+
+    mailProcessor.process(b.build());
+
+    // Assert messages
+    Collection<ChangeMessageInfo> messages =
+        gApi.changes().id(changeId).get().messages;
+    assertThat(messages).hasSize(3);
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo("Patch Set 1:\n(1 comment)");
+    assertThat(Iterables.getLast(messages).tag)
+        .isEqualTo("mailMessageId=some id");
+
+    // Assert comment
+    comments = gApi.changes().id(changeId).current().commentsAsList();
+    assertThat(comments).hasSize(3);
+    assertThat(comments.get(2).message)
+        .isEqualTo("Some Inline Comment");
+    assertThat(comments.get(2).tag)
+        .isEqualTo("mailMessageId=some id");
+    assertThat(comments.get(2).inReplyTo)
+        .isEqualTo(comments.get(1).id);
+  }
+
+  @Test
+  public void parseAndPersistFileComment() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId)
+        .current().commentsAsList();
+    String ts = MailUtil.rfcDateformatter.format(ZonedDateTime.ofInstant(
+        comments.get(0).updated.toInstant(),
+        ZoneId.of("UTC")));
+
+    // Build Message
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt = newPlaintextBody(canonicalWebUrl.get() + "#/c/" +
+        changeInfo._number + "/1", null, null, "Some Comment on File 1", null);
+    b.textContent(txt + textFooterForChange(changeId, ts));
+
+    mailProcessor.process(b.build());
+
+    // Assert messages
+    Collection<ChangeMessageInfo> messages =
+        gApi.changes().id(changeId).get().messages;
+    assertThat(messages).hasSize(3);
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo("Patch Set 1:\n(1 comment)");
+    assertThat(Iterables.getLast(messages).tag)
+        .isEqualTo("mailMessageId=some id");
+
+    // Assert comment
+    comments = gApi.changes().id(changeId).current().commentsAsList();
+    assertThat(comments).hasSize(3);
+    assertThat(comments.get(0).message).isEqualTo("Some Comment on File 1");
+    assertThat(comments.get(0).inReplyTo).isNull();
+    assertThat(comments.get(0).tag).isEqualTo("mailMessageId=some id");
+    assertThat(comments.get(0).path).isEqualTo("gerrit-server/test.txt");
+  }
+
+  @Test
+  public void parseAndPersistMessageTwice() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId)
+        .current().commentsAsList();
+    String ts = MailUtil.rfcDateformatter.format(ZonedDateTime.ofInstant(
+        comments.get(0).updated.toInstant(),
+        ZoneId.of("UTC")));
+
+    // Build Message
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt = newPlaintextBody(canonicalWebUrl.get() + "#/c/" +
+        changeInfo._number + "/1", null, "Some Inline Comment", null, null);
+    b.textContent(txt + textFooterForChange(changeId, ts));
+
+    mailProcessor.process(b.build());
+    comments = gApi.changes().id(changeId).current().commentsAsList();
+    assertThat(comments).hasSize(3);
+
+    // Check that the comment has not been persisted a second time
+    mailProcessor.process(b.build());
+    comments = gApi.changes().id(changeId).current().commentsAsList();
+    assertThat(comments).hasSize(3);
+  }
+
+  @Test
+  public void parseAndPersistMessageFromInactiveAccount() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId)
+        .current().commentsAsList();
+    String ts = MailUtil.rfcDateformatter.format(ZonedDateTime.ofInstant(
+        comments.get(0).updated.toInstant(),
+        ZoneId.of("UTC")));
+    assertThat(comments).hasSize(2);
+
+    // Build Message
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt = newPlaintextBody(canonicalWebUrl.get() + "#/c/" +
+        changeInfo._number + "/1", null, "Some Inline Comment", null, null);
+    b.textContent(txt + textFooterForChange(changeId, ts));
+
+    // Set account state to inactive
+    gApi.accounts().id("user").setActive(false);
+
+    mailProcessor.process(b.build());
+    comments = gApi.changes().id(changeId).current().commentsAsList();
+
+    // Check that comment size has not changed
+    assertThat(comments).hasSize(2);
+
+    // Reset
+    gApi.accounts().id("user").setActive(true);
+  }
+
+  private static CommentInput newComment(String path, Side side, int line,
+      String message) {
+    CommentInput c = new CommentInput();
+    c.path = path;
+    c.side = side;
+    c.line = line != 0 ? line : null;
+    c.message = message;
+    if (line != 0) {
+      Comment.Range range = new Comment.Range();
+      range.startLine = line;
+      range.startCharacter = 1;
+      range.endLine = line;
+      range.endCharacter = 5;
+      c.range = range;
+    }
+    return c;
+  }
+
+  /**
+   * Create a plaintext message body with the specified comments.
+   *
+   * @param changeMessage
+   * @param c1 Comment in reply to first inline comment.
+   * @param f1 Comment on file one.
+   * @param fc1 Comment in reply to a comment of file 1.
+   * @return A string with all inline comments and the original quoted email.
+   */
+  private static String newPlaintextBody(String changeURL, String changeMessage,
+      String c1, String f1, String fc1) {
+    return (changeMessage == null ? "" : changeMessage + "\n") +
+        "> Foo Bar has posted comments on this change. (  \n" +
+        "> " + changeURL +" )\n" +
+        "> \n" +
+        "> Change subject: Test change\n" +
+        "> ...............................................................\n" +
+        "> \n" +
+        "> \n" +
+        "> Patch Set 1: Code-Review+1\n" +
+        "> \n" +
+        "> (3 comments)\n" +
+        "> \n" +
+        "> " + changeURL + "/gerrit-server/test.txt\n" +
+        "> File  \n" +
+        "> gerrit-server/test.txt:\n" +
+        (f1 == null ? "" : f1 + "\n") +
+        "> \n" +
+        "> Patch Set #4:\n" +
+        "> " + changeURL + "/gerrit-server/test.txt\n" +
+        "> \n" +
+        "> Some comment" +
+        "> \n" +
+        (fc1 == null ? "" : fc1 + "\n") +
+        "> " + changeURL + "/gerrit-server/test.txt@2\n" +
+        "> PS1, Line 2: throw new Exception(\"Object has unsupported: \" +\n" +
+        ">               :             entry.getValue() +\n" +
+        ">               :             \" must be java.util.Date\");\n" +
+        "> Should entry.getKey() be included in this message?\n" +
+        "> \n" +
+        (c1 == null ? "" : c1 + "\n") +
+        "> \n";
+  }
+
+  private static String textFooterForChange(String changeId, String timestamp) {
+    return "Gerrit-Change-Id: " + changeId + "\n" +
+        "Gerrit-PatchSet: 1\n" +
+        "Gerrit-MessageType: comment\n" +
+        "Gerrit-Comment-Date: " + timestamp + "\n";
+  }
+
+  private MailMessage.Builder messageBuilderWithDefaultFields() {
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("some id");
+    Address address = new Address(user.fullName, user.email);
+    b.from(address);
+    b.addTo(address);
+    b.subject("");
+    b.dateReceived(new DateTime());
+    return b;
+  }
+
+  private String createChangeWithReview() throws Exception {
+    // Create change
+    String file = "gerrit-server/test.txt";
+    String contents = "contents \nlorem \nipsum \nlorem";
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,
+        "first subject", file, contents);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    String changeId = r.getChangeId();
+
+    // Review it
+    ReviewInput input = new ReviewInput();
+    input.message = "I have two comments";
+    input.comments = new HashMap<>();
+    CommentInput c1 = newComment(file, Side.REVISION, 0, "comment on file");
+    CommentInput c2 = newComment(file, Side.REVISION, 2, "inline comment");
+    input.comments.put(c1.path, ImmutableList.of(c1, c2));
+    revision(r).review(input);
+    return changeId;
+  }
+}
diff --git a/gerrit-server/BUCK b/gerrit-server/BUCK
index 1e71009..6fc7c4b 100644
--- a/gerrit-server/BUCK
+++ b/gerrit-server/BUCK
@@ -64,6 +64,7 @@
     '//lib/jgit/org.eclipse.jgit:jgit',
     '//lib/jgit/org.eclipse.jgit.archive:jgit-archive',
     '//lib/joda:joda-time',
+    '//lib/jsoup:jsoup',
     '//lib/log:api',
     '//lib/log:jsonevent-layout',
     '//lib/log:log4j',
diff --git a/gerrit-server/BUILD b/gerrit-server/BUILD
index b390f3e..6ca423f 100644
--- a/gerrit-server/BUILD
+++ b/gerrit-server/BUILD
@@ -68,6 +68,7 @@
         "//lib/jgit/org.eclipse.jgit.archive:jgit-archive",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/joda:joda-time",
+        "//lib/jsoup:jsoup",
         "//lib/log:api",
         "//lib/log:jsonevent-layout",
         "//lib/log:log4j",
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
index d664de9..9111cd4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
@@ -25,6 +25,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.reviewdb.client.Account;
@@ -116,6 +117,13 @@
     return new PatchSet.Id(changeId, comment.key.patchSetId);
   }
 
+  public static String extractMessageId(@Nullable String tag) {
+    if (tag == null || !tag.startsWith("mailMessageId=")) {
+      return null;
+    }
+    return tag.substring("mailMessageId=".length());
+  }
+
   private static final Ordering<Comparable<?>> NULLS_FIRST =
       Ordering.natural().nullsFirst();
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/HtmlParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/HtmlParser.java
new file mode 100644
index 0000000..6b81d35
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/HtmlParser.java
@@ -0,0 +1,125 @@
+// Copyright (C) 2016 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.google.gerrit.server.mail.receive;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterators;
+import com.google.common.collect.PeekingIterator;
+import com.google.gerrit.reviewdb.client.Comment;
+
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/** HTMLParser provides parsing functionality for html email. */
+public class HtmlParser {
+  /**
+   * Parses comments from html email.
+   *
+   * @param email MailMessage as received from the email service.
+   * @param comments A specific set of comments as sent out in the original
+   *                 notification email. Comments are expected to be in the same
+   *                 order as they were sent out to in the email
+   * @param changeUrl Canonical change URL that points to the change on this
+   *                  Gerrit instance.
+   *                  Example: https://go-review.googlesource.com/#/c/91570
+   * @return List of MailComments parsed from the html part of the email.
+   */
+  public static List<MailComment> parse(MailMessage email,
+      Collection<Comment> comments, String changeUrl) {
+    // TODO(hiesel) Add support for Gmail Mobile
+    // TODO(hiesel) Add tests for other popular email clients
+
+    // This parser goes though all html elements in the email and checks for
+    // matching patterns. It keeps track of the last file and comments it
+    // encountered to know in which context a parsed comment belongs.
+    // It uses the href attributes of <a> tags to identify comments sent out by
+    // Gerrit as these are generally more reliable then the text captions.
+    List<MailComment> parsedComments = new ArrayList<>();
+    Document d = Jsoup.parse(email.htmlContent());
+    PeekingIterator<Comment> iter =
+        Iterators.peekingIterator(comments.iterator());
+
+    String lastEncounteredFileName = null;
+    Comment lastEncounteredComment = null;
+    for (Element e : d.body().getAllElements()) {
+      String elementName = e.tagName();
+      boolean isInBlockQuote = e.parents().stream()
+          .filter(p -> p.tagName().equals("blockquote"))
+          .findAny()
+          .isPresent();
+
+      if (elementName.equals("a")) {
+        String href = e.attr("href");
+        // Check if there is still a next comment that could be contained in
+        // this <a> tag
+        if (!iter.hasNext()) {
+          continue;
+        }
+        Comment perspectiveComment = iter.peek();
+        if (href.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
+          if (lastEncounteredFileName == null || !lastEncounteredFileName
+              .equals(perspectiveComment.key.filename)) {
+            // Not a file-level comment, but users could have typed a comment
+            // right after this file annotation to create a new file-level
+            // comment. If this file has a file-level comment, we have already
+            // set lastEncounteredComment to that file-level comment when we
+            // encountered the file link and should not reset it now.
+            lastEncounteredFileName = perspectiveComment.key.filename;
+            lastEncounteredComment = null;
+          } else if (perspectiveComment.lineNbr == 0) {
+            // This was originally a file-level comment
+            lastEncounteredComment = perspectiveComment;
+            iter.next();
+          }
+        } else if (ParserUtil.isCommentUrl(href, changeUrl,
+            perspectiveComment)) {
+          // This is a regular inline comment
+          lastEncounteredComment = perspectiveComment;
+          iter.next();
+        }
+      } else if (!isInBlockQuote && elementName.equals("div") &&
+          !e.className().startsWith("gmail")) {
+        // This is a comment typed by the user
+        String content = e.ownText().trim();
+        if (!Strings.isNullOrEmpty(content)) {
+          if (lastEncounteredComment == null &&
+              lastEncounteredFileName == null) {
+            // Remove quotation line, email signature and
+            // "Sent from my xyz device"
+            content = ParserUtil.trimQuotationLine(content);
+            // TODO(hiesel) Add more sanitizer
+            if (!Strings.isNullOrEmpty(content)) {
+              parsedComments.add(new MailComment(content, null, null,
+                  MailComment.CommentType.CHANGE_MESSAGE));
+            }
+          } else if (lastEncounteredComment == null) {
+            parsedComments.add(new MailComment(content, lastEncounteredFileName,
+                null, MailComment.CommentType.FILE_COMMENT));
+          } else {
+            parsedComments.add(new MailComment(content, null,
+                lastEncounteredComment,
+                MailComment.CommentType.INLINE_COMMENT));
+          }
+        }
+      }
+    }
+    return parsedComments;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
index 00f3091..ce2a834 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.receive;
 
+import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.mail.Encryption;
 import com.google.inject.Inject;
@@ -35,16 +36,19 @@
   private static final String INBOX_FOLDER = "INBOX";
 
   @Inject
-  public ImapMailReceiver(EmailSettings mailSettings) {
-    super(mailSettings);
+  ImapMailReceiver(EmailSettings mailSettings,
+      MailProcessor mailProcessor,
+      WorkQueue workQueue) {
+    super(mailSettings, mailProcessor, workQueue);
   }
 
   /**
    * handleEmails will open a connection to the mail server, remove emails
    * where deletion is pending, read new email and close the connection.
+   * @param async Determines if processing messages should happen asynchronous.
    */
   @Override
-  public synchronized void handleEmails() {
+  public synchronized void handleEmails(boolean async) {
     IMAPClient imap;
     if (mailSettings.encryption != Encryption.NONE) {
       imap = new IMAPSClient(mailSettings.encryption.name(), false);
@@ -128,7 +132,7 @@
           if (!imap.expunge()) {
             log.error("Could not expunge IMAP emails");
           }
-          // TODO(hiesel) Call email handling logic with mailMessages
+          dispatchMailProcessor(mailMessages, async);
         } finally {
           imap.logout();
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailComment.java
new file mode 100644
index 0000000..4144cfc
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailComment.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2016 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.google.gerrit.server.mail.receive;
+
+import com.google.gerrit.reviewdb.client.Comment;
+
+/** A comment parsed from inbound email */
+public class MailComment {
+  enum CommentType {
+    CHANGE_MESSAGE,
+    FILE_COMMENT,
+    INLINE_COMMENT
+  }
+
+  CommentType type;
+  Comment inReplyTo;
+  String fileName;
+  String message;
+
+  public MailComment() { }
+
+  public MailComment(String message, String fileName, Comment inReplyTo,
+      CommentType type) {
+    this.message = message;
+    this.fileName = fileName;
+    this.inReplyTo = inReplyTo;
+    this.type = type;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
new file mode 100644
index 0000000..4aa7ec0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -0,0 +1,270 @@
+// Copyright (C) 2016 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.google.gerrit.server.mail.receive;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountByEmailCache;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class MailProcessor {
+  private static final Logger log =
+      LoggerFactory.getLogger(MailProcessor.class.getName());
+
+  private final AccountByEmailCache accountByEmailCache;
+  private final BatchUpdate.Factory buf;
+  private final ChangeMessagesUtil changeMessagesUtil;
+  private final CommentsUtil commentsUtil;
+  private final OneOffRequestContext oneOffRequestContext;
+  private final PatchListCache patchListCache;
+  private final PatchSetUtil psUtil;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Provider<ReviewDb> reviewDb;
+  private final Provider<String> canonicalUrl;
+
+  @Inject
+  public MailProcessor(AccountByEmailCache accountByEmailCache,
+      BatchUpdate.Factory buf,
+      ChangeMessagesUtil changeMessagesUtil,
+      CommentsUtil commentsUtil,
+      OneOffRequestContext oneOffRequestContext,
+      PatchListCache patchListCache,
+      PatchSetUtil psUtil,
+      Provider<InternalChangeQuery> queryProvider,
+      Provider<ReviewDb> reviewDb,
+      @CanonicalWebUrl Provider<String> canonicalUrl) {
+    this.accountByEmailCache = accountByEmailCache;
+    this.buf = buf;
+    this.changeMessagesUtil = changeMessagesUtil;
+    this.commentsUtil = commentsUtil;
+    this.oneOffRequestContext = oneOffRequestContext;
+    this.patchListCache = patchListCache;
+    this.psUtil = psUtil;
+    this.queryProvider = queryProvider;
+    this.reviewDb = reviewDb;
+    this.canonicalUrl = canonicalUrl;
+  }
+
+  /**
+   * Parse comments from MailMessage and persist them on the change.
+   * @param message MailMessage to process.
+   * @throws OrmException
+   */
+  public void process(MailMessage message) throws OrmException {
+    MailMetadata metadata = MetadataParser.parse(message);
+    if (!metadata.hasRequiredFields()) {
+      log.error("Mail: Message " + message.id() +
+          " is missing required metadata, have " + metadata +
+          ". Will delete message.");
+      return;
+    }
+
+    Set<Account.Id> accounts = accountByEmailCache.get(metadata.author);
+    if (accounts.size() != 1) {
+      log.error("Mail: Address " + metadata.author +
+          " could not be matched to a unique account. It was matched to " +
+          accounts + ". Will delete message.");
+      return;
+    }
+    Account.Id account = accounts.iterator().next();
+    if (!reviewDb.get().accounts().get(account).isActive()) {
+      log.warn("Mail: Account " + account +
+          " is inactive. Will delete message.");
+      return;
+    }
+
+    try (ManualRequestContext ctx = oneOffRequestContext.openAs(account)) {
+      ChangeData cd = queryProvider.get().setLimit(1)
+          .byKey(Change.Key.parse(metadata.changeId)).get(0);
+      if (existingMessageIds(cd).contains(message.id())) {
+        log.info("Mail: Message " + message.id() +
+            " was already processed. Will delete message.");
+        return;
+      }
+      // Get all comments; filter and sort them to get the original list of
+      // comments from the outbound email.
+      // TODO(hiesel) Also filter by original comment author.
+      Collection<Comment> comments = cd.publishedComments().stream()
+          .filter(c -> (c.writtenOn.getTime() / 1000) ==
+              (metadata.timestamp.getTime() / 1000))
+          .sorted(CommentsUtil.COMMENT_ORDER)
+          .collect(Collectors.toList());
+      Project.NameKey project = cd.project();
+      String changeUrl = canonicalUrl.get() + "#/c/" + cd.getId().get();
+
+      List<MailComment> parsedComments;
+      if (useHtmlParser(message)) {
+        parsedComments = HtmlParser.parse(message, comments, changeUrl);
+      } else {
+        parsedComments = TextParser.parse(message, comments, changeUrl);
+      }
+
+      if (parsedComments.isEmpty()) {
+        log.warn("Mail: Could not parse any comments from " + message.id() +
+            ". Will delete message.");
+        return;
+      }
+
+      Op o = new Op(new PatchSet.Id(cd.getId(), metadata.patchSet),
+          parsedComments, message.id());
+      BatchUpdate batchUpdate = buf.create(cd.db(), project, ctx.getUser(),
+          TimeUtil.nowTs());
+      batchUpdate.addOp(cd.getId(), o);
+      try {
+        batchUpdate.execute();
+      } catch (UpdateException | RestApiException e) {
+        throw new OrmException(e);
+      }
+    }
+  }
+
+  private class Op extends BatchUpdate.Op {
+    private final PatchSet.Id psId;
+    private final List<MailComment> parsedComments;
+    private final String tag;
+
+    private Op(PatchSet.Id psId, List<MailComment> parsedComments,
+        String messageId) {
+      this.psId = psId;
+      this.parsedComments = parsedComments;
+      this.tag = "mailMessageId=" + messageId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws OrmException {
+      PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      if (ps == null) {
+        throw new OrmException("patch set not found: " + psId);
+      }
+
+      String changeMsg = "Patch Set " + psId.get() + ":";
+      if (parsedComments.get(0).type ==
+          MailComment.CommentType.CHANGE_MESSAGE) {
+        if (parsedComments.size() > 1) {
+          changeMsg += "\n" + numComments(parsedComments.size() - 1);
+        }
+        changeMsg += "\n" + parsedComments.get(0).message;
+      } else {
+        changeMsg += "\n" + numComments(parsedComments.size());
+      }
+
+      ChangeMessage msg = ChangeMessagesUtil.newMessage(ctx, changeMsg, tag);
+      changeMessagesUtil.addChangeMessage(ctx.getDb(),
+          ctx.getUpdate(psId), msg);
+
+      List<Comment> comments = new ArrayList<>();
+      for (MailComment c : parsedComments) {
+        if (c.type == MailComment.CommentType.CHANGE_MESSAGE) {
+          continue;
+        }
+
+        String fileName;
+        // The patch set that this comment is based on is different if this
+        // comment was sent in reply to a comment on a previous patch set.
+        PatchSet psForComment;
+        Side side;
+        if (c.inReplyTo != null) {
+          fileName = c.inReplyTo.key.filename;
+          psForComment = psUtil.get(ctx.getDb(), ctx.getNotes(),
+              new PatchSet.Id(ctx.getChange().getId(),
+                  c.inReplyTo.key.patchSetId));
+          side = Side.fromShort(c.inReplyTo.side);
+        } else {
+          fileName = c.fileName;
+          psForComment = ps;
+          side = Side.REVISION;
+        }
+
+        Comment comment = commentsUtil.newComment(ctx, fileName,
+            psForComment.getId(), (short) side.ordinal(), c.message);
+        comment.tag = tag;
+        if (c.inReplyTo != null) {
+          comment.parentUuid = c.inReplyTo.key.uuid;
+          comment.lineNbr = c.inReplyTo.lineNbr;
+          comment.range = c.inReplyTo.range;
+        }
+        CommentsUtil.setCommentRevId(comment, patchListCache,
+            ctx.getChange(), psForComment);
+        comments.add(comment);
+      }
+      commentsUtil.putComments(ctx.getDb(),
+          ctx.getUpdate(ctx.getChange().currentPatchSetId()), Status.PUBLISHED,
+          comments);
+
+      return true;
+    }
+  }
+
+  private static boolean useHtmlParser(MailMessage m) {
+    return !Strings.isNullOrEmpty(m.htmlContent());
+  }
+
+  private static String numComments(int numComments) {
+    return "(" + numComments + (numComments > 1 ? " comments)" : " comment)");
+  }
+
+  private Set<String> existingMessageIds(ChangeData cd) throws OrmException {
+    Set<String> existingMessageIds = new HashSet<>();
+    cd.messages().stream().forEach(m -> {
+      String messageId = CommentsUtil.extractMessageId(m.getTag());
+      if (messageId != null) {
+        existingMessageIds.add(messageId);
+      }
+    });
+    cd.publishedComments().stream().forEach(c -> {
+      String messageId = CommentsUtil.extractMessageId(c.tag);
+      if (messageId != null) {
+        existingMessageIds.add(messageId);
+      }
+    });
+    return existingMessageIds;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java
index 7e75b2e..af76794 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java
@@ -17,19 +17,30 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.EmailSettings;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Set;
 import java.util.Timer;
 import java.util.TimerTask;
+import java.util.concurrent.Callable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** MailReceiver implements base functionality for receiving emails. */
 public abstract class MailReceiver implements LifecycleListener {
+  private static final Logger log =
+      LoggerFactory.getLogger(MailReceiver.class.getName());
+
   protected EmailSettings mailSettings;
   protected Set<String> pendingDeletion;
+  private MailProcessor mailProcessor;
+  private WorkQueue workQueue;
   private Timer timer;
 
   public static class Module extends LifecycleModule {
@@ -59,8 +70,11 @@
     }
   }
 
-  public MailReceiver(EmailSettings mailSettings) {
+  MailReceiver(EmailSettings mailSettings, MailProcessor mailProcessor,
+      WorkQueue workQueue) {
     this.mailSettings = mailSettings;
+    this.mailProcessor = mailProcessor;
+    this.workQueue = workQueue;
     pendingDeletion = Collections.synchronizedSet(new HashSet<>());
   }
 
@@ -74,7 +88,7 @@
     timer.scheduleAtFixedRate(new TimerTask() {
       @Override
       public void run() {
-        MailReceiver.this.handleEmails();
+        MailReceiver.this.handleEmails(true);
       }
     }, 0L, mailSettings.fetchInterval);
   }
@@ -99,7 +113,35 @@
   /**
    * handleEmails will open a connection to the mail server, remove emails
    * where deletion is pending, read new email and close the connection.
+   * @param async Determines if processing messages should happen asynchronous.
    */
   @VisibleForTesting
-  public abstract void handleEmails();
+  public abstract void handleEmails(boolean async);
+
+  protected void dispatchMailProcessor(List<MailMessage> messages,
+      boolean async) {
+    for (MailMessage m : messages) {
+      if (async) {
+        Callable<?> task = () -> {
+          try {
+            mailProcessor.process(m);
+            requestDeletion(m.id());
+          } catch (OrmException e) {
+            log.error("Mail: Can't process message " + m.id() +
+                " . Won't delete.", e);
+          }
+          return null;
+        };
+        workQueue.getDefaultQueue().submit(task);
+      } else {
+        // Synchronous processing is used only in tests.
+        try {
+          mailProcessor.process(m);
+          requestDeletion(m.id());
+        } catch (OrmException e) {
+          log.error("Mail: Can't process messages. Won't delete.", e);
+        }
+      }
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ParserUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ParserUtil.java
new file mode 100644
index 0000000..d6bdc24
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ParserUtil.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2016 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.google.gerrit.server.mail.receive;
+
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.regex.Pattern;
+
+public class ParserUtil {
+  private static final Pattern SIMPLE_EMAIL_PATTERN = Pattern.compile(
+      "[_A-Za-z0-9-\\+]+(\\.[_A-Za-z0-9-]+)*@[A-Za-z0-9-]+"
+          + "(\\.[A-Za-z0-9]+)*(\\.[A-Za-z]{2,})");
+
+  /**
+   * Trims the quotation line that email clients add
+   * Example: On Sun, Nov 20, 2016 at 10:33 PM, <gerrit@hiesel.it> wrote:
+   * @param comment Comment parsed from an email.
+   * @return Trimmed comment.
+   */
+  public static String trimQuotationLine(String comment) {
+    // Identifying the quotation line is hard, as it can be in any language.
+    // We identify this line by it's characteristics: It usually contains a
+    // valid email address, some digits for the date in groups of 1-4 in a row
+    // as well as some characters.
+    StringBuilder b = new StringBuilder();
+    for (String line : comment.split("\n")) {
+      // Count occurrences of digit groups
+      int numConsecutiveDigits = 0;
+      int maxConsecutiveDigits = 0;
+      int numDigitGroups = 0;
+      for (char c : line.toCharArray()) {
+        if (c >= '0' && c <= '9') {
+          numConsecutiveDigits++;
+        } else if (numConsecutiveDigits > 0) {
+          maxConsecutiveDigits = Integer.max(maxConsecutiveDigits,
+              numConsecutiveDigits);
+          numConsecutiveDigits = 0;
+          numDigitGroups++;
+        }
+      }
+      if (numDigitGroups < 4 || maxConsecutiveDigits > 4 ||
+          !SIMPLE_EMAIL_PATTERN.matcher(line).find()) {
+        b.append(line);
+      }
+    }
+    return b.toString().trim();
+  }
+
+  /** Check if string is an inline comment url on a patch set or the base */
+  public static boolean isCommentUrl(String str, String changeUrl,
+      Comment comment) {
+    return str.equals(filePath(changeUrl, comment) + "@" + comment.lineNbr) ||
+        str.equals(filePath(changeUrl, comment) + "@a" + comment.lineNbr);
+  }
+
+  /** Generate the fully qualified filepath */
+  public static String filePath(String changeUrl, Comment comment) {
+    return changeUrl + "/" + comment.key.patchSetId + "/" +
+        comment.key.filename;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
index 6c81011..d1498fd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.mail.receive;
 
 import com.google.common.primitives.Ints;
+import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.mail.Encryption;
 import com.google.inject.Inject;
@@ -37,16 +38,19 @@
       LoggerFactory.getLogger(Pop3MailReceiver.class);
 
   @Inject
-  public Pop3MailReceiver(EmailSettings mailSettings) {
-    super(mailSettings);
+  Pop3MailReceiver(EmailSettings mailSettings,
+      MailProcessor mailProcessor,
+      WorkQueue workQueue) {
+    super(mailSettings, mailProcessor, workQueue);
   }
 
   /**
    * handleEmails will open a connection to the mail server, remove emails
    * where deletion is pending, read new email and close the connection.
+   * @param async Determines if processing messages should happen asynchronous.
    */
   @Override
-  public synchronized void handleEmails() {
+  public synchronized void handleEmails(boolean async) {
     POP3Client pop3;
     if (mailSettings.encryption != Encryption.NONE) {
       pop3 = new POP3SClient(mailSettings.encryption.name());
@@ -111,7 +115,7 @@
               log.error("Could not parse message " + msginfo.number);
             }
           }
-          // TODO(hiesel) Call processing logic with mailMessages
+          dispatchMailProcessor(mailMessages, async);
         } finally {
           pop3.logout();
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/TextParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/TextParser.java
new file mode 100644
index 0000000..8b28df5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/TextParser.java
@@ -0,0 +1,141 @@
+// Copyright (C) 2016 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.google.gerrit.server.mail.receive;
+
+import com.google.common.collect.Iterators;
+import com.google.common.collect.PeekingIterator;
+import com.google.gerrit.reviewdb.client.Comment;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/** TextParser provides parsing functionality for plaintext email.  */
+public class TextParser {
+  /**
+   * Parses comments from plaintext email.
+   *
+   * @param email MailMessage as received from the email service.
+   * @param comments Comments previously persisted on the change that caused the
+   *                 original notification email to be sent out. Ordering must
+   *                 be the same as in the outbound email
+   * @param changeUrl Canonical change url that points to the change on this
+   *                  Gerrit instance.
+   *                  Example: https://go-review.googlesource.com/#/c/91570
+   * @return List of MailComments parsed from the plaintext part of the email.
+   */
+  public static List<MailComment> parse(
+      MailMessage email, Collection<Comment> comments, String changeUrl) {
+    String body = email.textContent();
+    // Replace CR-LF by \n
+    body = body.replace("\r\n", "\n");
+
+    List<MailComment> parsedComments = new ArrayList<>();
+
+    // Some email clients (like GMail) use >> for enquoting text when there are
+    // inline comments that the users typed. These will then be enquoted by a
+    // single >. We sanitize this by unifying it into >. Inline comments typed
+    // by the user will not be enquoted.
+    //
+    // Example:
+    // Some comment
+    // >> Quoted Text
+    // >> Quoted Text
+    // > A comment typed in the email directly
+    String singleQuotePattern = "\n> ";
+    String doubleQuotePattern = "\n>> ";
+    if (countOccurrences(body, doubleQuotePattern) >
+        countOccurrences(body, singleQuotePattern)) {
+      body = body.replace(doubleQuotePattern, singleQuotePattern);
+    }
+
+    PeekingIterator<Comment> iter =
+        Iterators.peekingIterator(comments.iterator());
+
+    String[] lines = body.split("\n");
+    MailComment currentComment = null;
+    String lastEncounteredFileName = null;
+    Comment lastEncounteredComment = null;
+    for (String line : lines) {
+      if (line.startsWith("> ")) {
+        line = line.substring("> ".length()).trim();
+        // This is not a comment, try to advance the file/comment pointers and
+        // add previous comment to list if applicable
+        if (currentComment != null) {
+          parsedComments.add(currentComment);
+          currentComment = null;
+        }
+
+        if (!iter.hasNext()) {
+          continue;
+        }
+        Comment perspectiveComment = iter.peek();
+        if (line.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
+          if (lastEncounteredFileName == null ||
+              !lastEncounteredFileName
+                  .equals(perspectiveComment.key.filename)) {
+            // This is the annotation of a file
+            lastEncounteredFileName = perspectiveComment.key.filename;
+            lastEncounteredComment = null;
+          } else if (perspectiveComment.lineNbr == 0) {
+            // This was originally a file-level comment
+            lastEncounteredComment = perspectiveComment;
+            iter.next();
+          }
+        } else if (ParserUtil.isCommentUrl(line, changeUrl,
+            perspectiveComment)) {
+          lastEncounteredComment = perspectiveComment;
+          iter.next();
+        }
+      } else {
+        // This is a comment. Try to append to previous comment if applicable or
+        // create a new comment.
+        if (currentComment == null) {
+          // Start new comment
+          currentComment = new MailComment();
+          currentComment.message = line;
+          if (lastEncounteredComment == null) {
+            if (lastEncounteredFileName == null) {
+              // Change message
+              currentComment.type = MailComment.CommentType.CHANGE_MESSAGE;
+            } else {
+              // File comment not sent in reply to another comment
+              currentComment.type = MailComment.CommentType.FILE_COMMENT;
+              currentComment.fileName = lastEncounteredFileName;
+            }
+          } else {
+            // Comment sent in reply to another comment
+            currentComment.inReplyTo = lastEncounteredComment;
+            currentComment.type = MailComment.CommentType.INLINE_COMMENT;
+          }
+        } else {
+          // Attach to previous comment
+          currentComment.message += "\n" + line;
+        }
+      }
+    }
+    // There is no need to attach the currentComment after this loop as all
+    // emails have footers and other enquoted text after the last comment
+    // appeared and the last comment will have already been added to the list
+    // at this point.
+
+    return parsedComments;
+  }
+
+  /** Counts the occurrences of pattern in s */
+  private static int countOccurrences(String s, String pattern) {
+    return (s.length() - s.replace(pattern, "").length()) / pattern.length();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java
new file mode 100644
index 0000000..3ffd257
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/AbstractParserTest.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2016 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.google.gerrit.server.mail.receive;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.server.mail.Address;
+import java.util.ArrayList;
+import java.util.List;
+import org.joda.time.DateTime;
+
+import java.sql.Timestamp;
+import org.junit.Ignore;
+
+import static com.google.common.truth.Truth.assertThat;
+
+@Ignore
+public class AbstractParserTest {
+  protected static final String changeURL =
+      "https://gerrit-review.googlesource.com/#/changes/123";
+
+  protected static void assertChangeMessage(String message,
+      MailComment comment) {
+    assertThat(comment.fileName).isNull();
+    assertThat(comment.message).isEqualTo(message);
+    assertThat(comment.inReplyTo).isNull();
+    assertThat(comment.type).isEqualTo(MailComment.CommentType.CHANGE_MESSAGE);
+  }
+
+  protected static void assertInlineComment(String message, MailComment comment,
+      Comment inReplyTo) {
+    assertThat(comment.fileName).isNull();
+    assertThat(comment.message).isEqualTo(message);
+    assertThat(comment.inReplyTo).isEqualTo(inReplyTo);
+    assertThat(comment.type).isEqualTo(MailComment.CommentType.INLINE_COMMENT);
+  }
+
+  protected static void assertFileComment(String message, MailComment comment,
+      String file) {
+    assertThat(comment.fileName).isEqualTo(file);
+    assertThat(comment.message).isEqualTo(message);
+    assertThat(comment.inReplyTo).isNull();
+    assertThat(comment.type).isEqualTo(MailComment.CommentType.FILE_COMMENT);
+  }
+
+  protected static Comment newComment(String uuid, String file,
+      String message, int line) {
+    Comment c = new Comment(new Comment.Key(uuid, file, 1),
+        new Account.Id(0), new Timestamp(0l), (short) 0, message, "");
+    c.lineNbr = line;
+    return c;
+  }
+
+  /** Returns a MailMessage.Builder with all required fields populated. */
+  protected static MailMessage.Builder newMailMessageBuilder() {
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("id");
+    b.from(new Address("Foo Bar", "foo@bar.com"));
+    b.dateReceived(new DateTime());
+    b.subject("");
+    return b;
+  }
+
+  /** Returns a List of default comments for testing. */
+  protected static List<Comment> defaultComments() {
+    List<Comment> comments = new ArrayList<>();
+    comments.add(newComment("c1", "gerrit-server/test.txt", "comment", 0));
+    comments.add(newComment("c2", "gerrit-server/test.txt", "comment", 2));
+    comments.add(newComment("c3", "gerrit-server/test.txt", "comment", 3));
+    comments.add(newComment("c4", "gerrit-server/readme.txt", "comment", 3));
+    return comments;
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java
new file mode 100644
index 0000000..7eadf01
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GenericHtmlParserTest.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2016 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.google.gerrit.server.mail.receive;
+
+/** Test parser for a generic Html email client response */
+public class GenericHtmlParserTest extends HtmlParserTest {
+  @Override
+  protected String newHtmlBody(String changeMessage, String c1,
+      String c2, String c3, String f1, String f2, String fc1) {
+    String email = "" +
+        "<div dir=\"ltr\">" + (changeMessage != null ? changeMessage : "") +
+        "<div class=\"extra\"><br><div class=\"quote\">" +
+        "On Fri, Nov 18, 2016 at 11:15 AM, foobar (Gerrit) noreply@gerrit.com" +
+        "<span dir=\"ltr\">&lt;<a href=\"mailto:noreply@gerrit.com\" " +
+        "target=\"_blank\">noreply@gerrit.com</a>&gt;</span> wrote:<br>" +
+        "<blockquote class=\"quote\" " +
+        "<p>foobar <strong>posted comments</strong> on this change.</p>" +
+        "<p><a href=\"" + changeURL + "/1\" " +
+        "target=\"_blank\">View Change</a></p><div>Patch Set 2: CR-1\n" +
+        "\n" +
+        "(3 comments)</div><ul><li>" +
+        "<p>" + // File #1: test.txt
+        "<a href=\"" + changeURL + "/1/gerrit-server/test.txt\">" +
+        "File gerrit-server/<wbr>test.txt:</a></p>" +
+        commentBlock(f1) +
+        "<li><p>" +
+        "<a href=\"" + changeURL + "/1/gerrit-server/test.txt\">" +
+        "Patch Set #2:</a> </p>" +
+        "<blockquote><pre>Some inline comment from Gerrit</pre>" +
+        "</blockquote><p>Some comment on file 1</p>" +
+        "</li>" +
+        commentBlock(fc1) +
+        "<li><p>" +
+        "<a href=\"" + changeURL + "/1/gerrit-server/test.txt@2\">" +
+        "Patch Set #2, Line 31:</a> </p>" +
+        "<blockquote><pre>Some inline comment from Gerrit</pre>" +
+        "</blockquote><p>Some text from original comment</p>" +
+        "</li>" +
+        commentBlock(c1) +
+        "" + // Inline comment #2
+        "<li><p>" +
+        "<a href=\"" + changeURL + "/1/gerrit-server/test.txt@3\">" +
+        "Patch Set #2, Line 47:</a> </p>" +
+        "<blockquote><pre>Some comment posted on Gerrit</pre>" +
+        "</blockquote><p>Some more comments from Gerrit</p>" +
+        "</li>" +
+        commentBlock(c2) +
+        "<li><p>" +
+        "<a href=\"" + changeURL + "/1/gerrit-server/test.txt@115\">" +
+        "Patch Set #2, Line 115:</a> <code>some code</code></p>" +
+        "<p>some comment</p></li></ul></li>" +
+        "" +
+        "<li><p>" + // File #2: test.txt
+        "<a href=\"" + changeURL + "/1/gerrit-server/readme.txt\">" +
+        "File gerrit-server/<wbr>readme.txt:</a></p>" +
+        commentBlock(f2) +
+        "<li><p>" +
+        "<a href=\"" + changeURL + "/1/gerrit-server/readme.txt@3\">" +
+        "Patch Set #2, Line 31:</a> </p>" +
+        "<blockquote><pre>Some inline comment from Gerrit</pre>" +
+        "</blockquote><p>Some text from original comment</p>" +
+        "</li>" +
+        commentBlock(c3) +
+        "" + // Inline comment #2
+        "</ul></li></ul>" +
+        "" + // Footer
+        "<p>To view, visit <a href=\"" + changeURL + "/1\">this change</a>. " +
+        "To unsubscribe, visit <a href=\"https://someurl\">settings</a>." +
+        "</p><p>Gerrit-MessageType: comment<br>" +
+        "Footer omitted</p>" +
+        "<div><div></div></div>" +
+        "<p>Gerrit-HasComments: Yes</p></blockquote></div><br></div></div>";
+    return email;
+  }
+
+  private static String commentBlock(String comment) {
+    if (comment == null) {
+      return "";
+    }
+    return "</ul></li></ul></blockquote><div>" + comment +
+        "</div><blockquote class=\"quote\"><ul><li><ul>";
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java
new file mode 100644
index 0000000..7000e46
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/GmailHtmlParserTest.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2016 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.google.gerrit.server.mail.receive;
+
+public class GmailHtmlParserTest extends HtmlParserTest {
+  @Override
+  protected String newHtmlBody(String changeMessage, String c1,
+      String c2, String c3, String f1, String f2, String fc1) {
+    String email = "" +
+        "<div dir=\"ltr\">" + (changeMessage != null ? changeMessage : "") +
+        "<div class=\"gmail_extra\"><br><div class=\"gmail_quote\">" +
+        "On Fri, Nov 18, 2016 at 11:15 AM, foobar (Gerrit) noreply@gerrit.com" +
+        "<span dir=\"ltr\">&lt;<a href=\"mailto:noreply@gerrit.com\" " +
+        "target=\"_blank\">noreply@gerrit.com</a>&gt;</span> wrote:<br>" +
+        "<blockquote class=\"gmail_quote\" " +
+        "<p>foobar <strong>posted comments</strong> on this change.</p>" +
+        "<p><a href=\"" + changeURL + "/1\" " +
+        "target=\"_blank\">View Change</a></p><div>Patch Set 2: CR-1\n" +
+        "\n" +
+        "(3 comments)</div><ul><li>" +
+        "<p>" + // File #1: test.txt
+        "<a href=\"" + changeURL + "/1/gerrit-server/test.txt\">" +
+        "File gerrit-server/<wbr>test.txt:</a></p>" +
+        commentBlock(f1) +
+        "<li><p>" +
+        "<a href=\"" + changeURL + "/1/gerrit-server/test.txt\">" +
+        "Patch Set #2:</a> </p>" +
+        "<blockquote><pre>Some inline comment from Gerrit</pre>" +
+        "</blockquote><p>Some comment on file 1</p>" +
+        "</li>" +
+        commentBlock(fc1) +
+        "<li><p>" +
+        "<a href=\"" + changeURL + "/1/gerrit-server/test.txt@2\">" +
+        "Patch Set #2, Line 31:</a> </p>" +
+        "<blockquote><pre>Some inline comment from Gerrit</pre>" +
+        "</blockquote><p>Some text from original comment</p>" +
+        "</li>" +
+        commentBlock(c1) +
+        "" + // Inline comment #2
+        "<li><p>" +
+        "<a href=\"" + changeURL + "/1/gerrit-server/test.txt@3\">" +
+        "Patch Set #2, Line 47:</a> </p>" +
+        "<blockquote><pre>Some comment posted on Gerrit</pre>" +
+        "</blockquote><p>Some more comments from Gerrit</p>" +
+        "</li>" +
+        commentBlock(c2) +
+        "<li><p>" +
+        "<a href=\"" + changeURL + "/1/gerrit-server/test.txt@115\">" +
+        "Patch Set #2, Line 115:</a> <code>some code</code></p>" +
+        "<p>some comment</p></li></ul></li>" +
+        "" +
+        "<li><p>" + // File #2: test.txt
+        "<a href=\"" + changeURL + "/1/gerrit-server/readme.txt\">" +
+        "File gerrit-server/<wbr>readme.txt:</a></p>" +
+        commentBlock(f2) +
+        "<li><p>" +
+        "<a href=\"" + changeURL + "/1/gerrit-server/readme.txt@3\">" +
+        "Patch Set #2, Line 31:</a> </p>" +
+        "<blockquote><pre>Some inline comment from Gerrit</pre>" +
+        "</blockquote><p>Some text from original comment</p>" +
+        "</li>" +
+        commentBlock(c3) +
+        "" + // Inline comment #2
+        "</ul></li></ul>" +
+        "" + // Footer
+        "<p>To view, visit <a href=\"" + changeURL + "/1\">this change</a>. " +
+        "To unsubscribe, visit <a href=\"https://someurl\">settings</a>." +
+        "</p><p>Gerrit-MessageType: comment<br>" +
+        "Footer omitted</p>" +
+        "<div><div></div></div>" +
+        "<p>Gerrit-HasComments: Yes</p></blockquote></div><br></div></div>";
+    return email;
+  }
+
+  private static String commentBlock(String comment) {
+    if (comment == null) {
+      return "";
+    }
+    return "</ul></li></ul></blockquote><div>" + comment +
+        "</div><blockquote class=\"gmail_quote\"><ul><li><ul>";
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/HtmlParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/HtmlParserTest.java
new file mode 100644
index 0000000..7ccca36
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/HtmlParserTest.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2016 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.google.gerrit.server.mail.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Comment;
+import java.util.List;
+import org.junit.Ignore;
+import org.junit.Test;
+
+@Ignore
+public abstract class HtmlParserTest extends AbstractParserTest {
+  @Test
+  public void simpleChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody("Looks good to me", null, null,
+        null, null, null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments =
+        HtmlParser.parse(b.build(), comments, "");
+
+    assertThat(parsedComments).hasSize(1);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+  }
+
+  @Test
+  public void simpleInlineComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody("Looks good to me",
+        "I have a comment on this.", null, "Also have a comment here.",
+        null, null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments =
+        HtmlParser.parse(b.build(), comments, changeURL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertInlineComment("I have a comment on this.", parsedComments.get(1),
+        comments.get(1));
+    assertInlineComment("Also have a comment here.", parsedComments.get(2),
+        comments.get(3));
+  }
+
+  @Test
+  public void simpleFileComment() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody("Looks good to me",
+        null, null, "Also have a comment here.",
+        "This is a nice file", null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments =
+        HtmlParser.parse(b.build(), comments, changeURL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertFileComment("This is a nice file", parsedComments.get(1),
+        comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(2),
+        comments.get(3));
+  }
+
+  @Test
+  public void noComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody(null, null, null, null, null, null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments =
+        HtmlParser.parse(b.build(), comments, changeURL);
+
+    assertThat(parsedComments).isEmpty();
+  }
+
+  @Test
+  public void noChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody(null, null, null,
+        "Also have a comment here.", "This is a nice file", null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments =
+        HtmlParser.parse(b.build(), comments, changeURL);
+
+    assertThat(parsedComments).hasSize(2);
+    assertFileComment("This is a nice file", parsedComments.get(0),
+        comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(1),
+        comments.get(3));
+  }
+
+  /**
+   * Create an html message body with the specified comments.
+   *
+   * @param changeMessage
+   * @param c1 Comment in reply to first comment.
+   * @param c2 Comment in reply to second comment.
+   * @param c3 Comment in reply to third comment.
+   * @param f1 Comment on file one.
+   * @param f2 Comment on file two.
+   * @param fc1 Comment in reply to a comment on file 1.
+   * @return A string with all inline comments and the original quoted email.
+   */
+  protected abstract String newHtmlBody(String changeMessage, String c1,
+      String c2, String c3, String f1, String f2, String fc1);
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/TextParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/TextParserTest.java
new file mode 100644
index 0000000..8d7205bd
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/TextParserTest.java
@@ -0,0 +1,218 @@
+// Copyright (C) 2016 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.google.gerrit.server.mail.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.Comment;
+import org.junit.Test;
+
+import java.util.List;
+
+public class TextParserTest extends AbstractParserTest {
+  private static final String quotedFooter = "" +
+      "> To view, visit https://gerrit-review.googlesource.com/123\n" +
+      "> To unsubscribe, visit https://gerrit-review.googlesource.com\n" +
+      "> \n" +
+      "> Gerrit-MessageType: comment\n" +
+      "> Gerrit-Change-Id: Ie1234021bf1e8d1425641af58fd648fc011db153\n" +
+      "> Gerrit-PatchSet: 1\n" +
+      "> Gerrit-Project: gerrit\n" +
+      "> Gerrit-Branch: master\n" +
+      "> Gerrit-Owner: Foo Bar <foo@bar.com>\n" +
+      "> Gerrit-HasComments: Yes";
+
+  @Test
+  public void simpleChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent("Looks good to me\n" + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments =
+        TextParser.parse(b.build(), comments, changeURL);
+
+    assertThat(parsedComments).hasSize(1);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+  }
+
+  @Test
+  public void simpleInlineComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(newPlaintextBody("Looks good to me",
+        "I have a comment on this.", null, "Also have a comment here.",
+        null, null, null) + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments =
+        TextParser.parse(b.build(), comments, changeURL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertInlineComment("I have a comment on this.", parsedComments.get(1),
+        comments.get(1));
+    assertInlineComment("Also have a comment here.", parsedComments.get(2),
+        comments.get(3));
+  }
+
+  @Test
+  public void simpleFileComment() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(newPlaintextBody("Looks good to me",
+        null, null, "Also have a comment here.",
+        "This is a nice file", null, null) + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments =
+        TextParser.parse(b.build(), comments, changeURL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertFileComment("This is a nice file", parsedComments.get(1),
+        comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(2),
+        comments.get(3));
+  }
+
+  @Test
+  public void noComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(newPlaintextBody(null, null, null, null, null, null, null) +
+        quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments =
+        TextParser.parse(b.build(), comments, changeURL);
+
+    assertThat(parsedComments).isEmpty();
+  }
+
+  @Test
+  public void noChangeMessage() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(newPlaintextBody(null, null, null,
+        "Also have a comment here.", "This is a nice file", null, null) +
+        quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments =
+        TextParser.parse(b.build(), comments, changeURL);
+
+    assertThat(parsedComments).hasSize(2);
+    assertFileComment("This is a nice file", parsedComments.get(0),
+        comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(1),
+        comments.get(3));
+  }
+
+  @Test
+  public void allCommentsGmail() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent((newPlaintextBody("Looks good to me",
+        null, null, "Also have a comment here.",
+        "This is a nice file", null, null) + quotedFooter)
+        .replace("> ", ">> "));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments =
+        TextParser.parse(b.build(), comments, changeURL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertFileComment("This is a nice file", parsedComments.get(1),
+        comments.get(1).key.filename);
+    assertInlineComment("Also have a comment here.", parsedComments.get(2),
+        comments.get(3));
+  }
+
+  @Test
+  public void replyToFileComment() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(newPlaintextBody("Looks good to me", null, null, null, null,
+        null, "Comment in reply to file comment") + quotedFooter);
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments =
+        TextParser.parse(b.build(), comments, changeURL);
+
+    assertThat(parsedComments).hasSize(2);
+    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertInlineComment("Comment in reply to file comment",
+        parsedComments.get(1), comments.get(0));
+  }
+
+  /**
+   * Create a plaintext message body with the specified comments.
+   *
+   * @param changeMessage
+   * @param c1 Comment in reply to first inline comment.
+   * @param c2 Comment in reply to second inline comment.
+   * @param c3 Comment in reply to third inline comment.
+   * @param f1 Comment on file one.
+   * @param f2 Comment on file two.
+   * @param fc1 Comment in reply to a comment of file 1.
+   * @return A string with all inline comments and the original quoted email.
+   */
+  private static String newPlaintextBody(String changeMessage, String c1,
+      String c2, String c3, String f1, String f2, String fc1) {
+    return (changeMessage == null ? "" : changeMessage + "\n") +
+        "> Foo Bar has posted comments on this change. (  \n" +
+        "> " + changeURL +"/1 )\n" +
+        "> \n" +
+        "> Change subject: Test change\n" +
+        "> ...............................................................\n" +
+        "> \n" +
+        "> \n" +
+        "> Patch Set 1: Code-Review+1\n" +
+        "> \n" +
+        "> (3 comments)\n" +
+        "> \n" +
+        "> " + changeURL + "/1/gerrit-server/test.txt\n" +
+        "> File  \n" +
+        "> gerrit-server/test.txt:\n" +
+        (f1 == null ? "" : f1 + "\n") +
+        "> \n" +
+        "> Patch Set #4:\n" +
+        "> " + changeURL + "/1/gerrit-server/test.txt\n" +
+        "> \n" +
+        "> Some comment" +
+        "> \n" +
+        (fc1 == null ? "" : fc1 + "\n") +
+        "> " + changeURL + "/1/gerrit-server/test.txt@2\n" +
+        "> PS1, Line 2: throw new Exception(\"Object has unsupported: \" +\n" +
+        ">               :             entry.getValue() +\n" +
+        ">               :             \" must be java.util.Date\");\n" +
+        "> Should entry.getKey() be included in this message?\n" +
+        "> \n" +
+        (c1 == null ? "" : c1 + "\n") +
+        "> \n" +
+        "> " + changeURL + "/1/gerrit-server/test.txt@3\n" +
+        "> PS1, Line 3: throw new Exception(\"Object has: \" +\n" +
+        ">               :             entry.getValue().getClass() +\n" +
+        ">              :             \" must be java.util.Date\");\n" +
+        "> same here\n" +
+        "> \n" +
+        (c2 == null ? "" : c2 + "\n") +
+        "> \n" +
+        "> " + changeURL + "/1/gerrit-server/readme.txt\n" +
+        "> File  \n" +
+        "> gerrit-server/readme.txt:\n" +
+        (f2 == null ? "" : f2 + "\n") +
+        "> \n" +
+        "> " + changeURL + "/1/gerrit-server/readme.txt@3\n" +
+        "> PS1, Line 3: E\n" +
+        "> Should this be EEE like in other places?\n" +
+        (c3 == null ? "" : c3 + "\n");
+  }
+}