Add abstract base class for testing notifications

This will be used for integration tests that make assertions about who
receives notification emails in response to particular actions.

Change-Id: Iea5b67a9988707a089c02a8b099138d84fbe07e0
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index f8f515c..07d39be 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -235,6 +235,7 @@
   protected TestAccount admin;
   protected TestAccount user;
   protected TestRepository<InMemoryRepository> testRepo;
+  protected String resourcePrefix;
 
   @Inject private ChangeIndexCollection changeIndexes;
   @Inject private EventRecorder.Factory eventRecorderFactory;
@@ -243,7 +244,6 @@
   @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
 
   private List<Repository> toClose;
-  private String resourcePrefix;
   private boolean useSsh;
 
   @Before
@@ -1230,16 +1230,36 @@
     assertThat(m.headers().get("CC").isEmpty()).isTrue();
   }
 
-  protected void watch(String project, String filter) throws RestApiException {
-    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+  protected interface ProjectWatchInfoConfiguration {
+    void configure(ProjectWatchInfo pwi);
+  }
+
+  protected void watch(String project, ProjectWatchInfoConfiguration config)
+      throws OrmException, RestApiException {
     ProjectWatchInfo pwi = new ProjectWatchInfo();
     pwi.project = project;
-    pwi.filter = filter;
-    pwi.notifyAbandonedChanges = true;
-    pwi.notifyNewChanges = true;
-    pwi.notifyAllComments = true;
-    projectsToWatch.add(pwi);
-    gApi.accounts().self().setWatchedProjects(projectsToWatch);
+    config.configure(pwi);
+    gApi.accounts().self().setWatchedProjects(ImmutableList.of(pwi));
+  }
+
+  protected void watch(PushOneCommit.Result r, ProjectWatchInfoConfiguration config)
+      throws OrmException, RestApiException {
+    watch(r.getChange().project().get(), config);
+  }
+
+  protected void watch(String project, String filter) throws OrmException, RestApiException {
+    watch(
+        project,
+        pwi -> {
+          pwi.filter = filter;
+          pwi.notifyAbandonedChanges = true;
+          pwi.notifyNewChanges = true;
+          pwi.notifyAllComments = true;
+        });
+  }
+
+  protected void watch(String project) throws OrmException, RestApiException {
+    watch(project, (String) null);
   }
 
   protected void assertContent(PushOneCommit.Result pushResult, String path, String expectedContent)
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
new file mode 100644
index 0000000..cf71f79
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -0,0 +1,435 @@
+// Copyright (C) 2017 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;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.extensions.api.changes.RecipientType.*;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
+import com.google.gerrit.testutil.FakeEmailSender;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Before;
+
+public abstract class AbstractNotificationTest extends AbstractDaemonTest {
+  @Before
+  public void enableReviewerByEmail() throws Exception {
+    setApiUser(admin);
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+  }
+
+  private static final SubjectFactory<FakeEmailSenderSubject, FakeEmailSender>
+      FAKE_EMAIL_SENDER_SUBJECT_FACTORY =
+          new SubjectFactory<FakeEmailSenderSubject, FakeEmailSender>() {
+            @Override
+            public FakeEmailSenderSubject getSubject(
+                FailureStrategy failureStrategy, FakeEmailSender target) {
+              return new FakeEmailSenderSubject(failureStrategy, target);
+            }
+          };
+
+  protected static FakeEmailSenderSubject assertThat(FakeEmailSender sender) {
+    return assertAbout(FAKE_EMAIL_SENDER_SUBJECT_FACTORY).that(sender);
+  }
+
+  protected void setEmailStrategy(TestAccount account, EmailStrategy strategy) throws Exception {
+    setApiUser(account);
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = strategy;
+    gApi.accounts().self().setPreferences(prefs);
+  }
+
+  protected static class FakeEmailSenderSubject
+      extends Subject<FakeEmailSenderSubject, FakeEmailSender> {
+    private Message message;
+    private StagedUsers users;
+    private Map<RecipientType, List<String>> recipients = new HashMap<>();
+
+    FakeEmailSenderSubject(FailureStrategy failureStrategy, FakeEmailSender target) {
+      super(failureStrategy, target);
+    }
+
+    public FakeEmailSenderSubject notSent() {
+      if (actual().peekMessage() != null) {
+        fail("a message wasn't sent");
+      }
+      return this;
+    }
+
+    public FakeEmailSenderSubject sent(String messageType, StagedUsers users) {
+      message = actual().nextMessage();
+      if (message == null) {
+        fail("a message was sent");
+      }
+      recipients = new HashMap<>();
+      recipients.put(TO, parseAddresses(message, "To"));
+      recipients.put(CC, parseAddresses(message, "CC"));
+      recipients.put(
+          BCC,
+          message
+              .rcpt()
+              .stream()
+              .map(Address::getEmail)
+              .filter(e -> !recipients.get(TO).contains(e) && !recipients.get(CC).contains(e))
+              .collect(Collectors.toList()));
+      this.users = users;
+      if (!message.headers().containsKey("X-Gerrit-MessageType")) {
+        fail("a message was sent with X-Gerrit-MessageType header");
+      }
+      EmailHeader header = message.headers().get("X-Gerrit-MessageType");
+      if (!header.equals(new EmailHeader.String(messageType))) {
+        fail("message of type " + messageType + " was sent; X-Gerrit-MessageType is " + header);
+      }
+
+      // Return a named subject that displays a human-readable table of
+      // recipients.
+      StringBuilder buf = new StringBuilder();
+      buf.append('[');
+      for (RecipientType type : ImmutableList.of(TO, CC, BCC)) {
+        buf.append('\n');
+        buf.append(type);
+        buf.append(':');
+        String delim = " ";
+        for (String r : recipients.get(type)) {
+          buf.append(delim);
+          buf.append(users.emailToName(r));
+          delim = ", ";
+        }
+      }
+      buf.append("\n]");
+      return named(buf.toString());
+    }
+
+    List<String> parseAddresses(Message msg, String headerName) {
+      EmailHeader header = msg.headers().get(headerName);
+      if (header == null) {
+        return ImmutableList.of();
+      }
+      Truth.assertThat(header).isInstanceOf(AddressList.class);
+      AddressList addrList = (AddressList) header;
+      return addrList.getAddressList().stream().map(Address::getEmail).collect(Collectors.toList());
+    }
+
+    public FakeEmailSenderSubject to(String... emails) {
+      return rcpt(users.supportReviewersByEmail ? TO : null, emails);
+    }
+
+    public FakeEmailSenderSubject cc(String... emails) {
+      return rcpt(users.supportReviewersByEmail ? CC : null, emails);
+    }
+
+    public FakeEmailSenderSubject bcc(String... emails) {
+      return rcpt(users.supportReviewersByEmail ? BCC : null, emails);
+    }
+
+    private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, String[] emails) {
+      for (String email : emails) {
+        rcpt(type, email);
+      }
+      return this;
+    }
+
+    private void rcpt(@Nullable RecipientType type, String email) {
+      rcpt(TO, email, TO.equals(type));
+      rcpt(CC, email, CC.equals(type));
+      rcpt(BCC, email, BCC.equals(type));
+    }
+
+    private void rcpt(@Nullable RecipientType type, String email, boolean expected) {
+      if (recipients.get(type).contains(email) != expected) {
+        fail(
+            expected ? "notifies" : "doesn't notify",
+            "]\n" + type + ": " + users.emailToName(email) + "\n]");
+      }
+    }
+
+    public FakeEmailSenderSubject notTo(String... emails) {
+      return rcpt(null, emails);
+    }
+
+    public FakeEmailSenderSubject to(TestAccount... accounts) {
+      return rcpt(TO, accounts);
+    }
+
+    public FakeEmailSenderSubject cc(TestAccount... accounts) {
+      return rcpt(CC, accounts);
+    }
+
+    public FakeEmailSenderSubject bcc(TestAccount... accounts) {
+      return rcpt(BCC, accounts);
+    }
+
+    public FakeEmailSenderSubject notTo(TestAccount... accounts) {
+      return rcpt(null, accounts);
+    }
+
+    private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, TestAccount[] accounts) {
+      for (TestAccount account : accounts) {
+        rcpt(type, account);
+      }
+      return this;
+    }
+
+    private void rcpt(@Nullable RecipientType type, TestAccount account) {
+      rcpt(type, account.email);
+    }
+
+    public FakeEmailSenderSubject to(NotifyType... watches) {
+      return rcpt(TO, watches);
+    }
+
+    public FakeEmailSenderSubject cc(NotifyType... watches) {
+      return rcpt(CC, watches);
+    }
+
+    public FakeEmailSenderSubject bcc(NotifyType... watches) {
+      return rcpt(BCC, watches);
+    }
+
+    public FakeEmailSenderSubject notTo(NotifyType... watches) {
+      return rcpt(null, watches);
+    }
+
+    private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, NotifyType[] watches) {
+      for (NotifyType watch : watches) {
+        rcpt(type, watch);
+      }
+      return this;
+    }
+
+    private void rcpt(@Nullable RecipientType type, NotifyType watch) {
+      if (!users.watchers.containsKey(watch)) {
+        fail("configured to watch", watch);
+      }
+      rcpt(type, users.watchers.get(watch));
+    }
+  }
+
+  protected class StagedUsers {
+    public final TestAccount owner;
+    public final TestAccount author;
+    public final TestAccount uploader;
+    public final TestAccount reviewer;
+    public final TestAccount ccer;
+    public final TestAccount starrer;
+    public final TestAccount watchingProjectOwner;
+    public final String reviewerByEmail = "reviewerByEmail@example.com";
+    public final String ccerByEmail = "ccByEmail@example.com";
+    private final Map<NotifyType, TestAccount> watchers = new HashMap<>();
+    private final Map<String, TestAccount> accountsByEmail = new HashMap<>();
+    boolean supportReviewersByEmail;
+
+    StagedUsers(List<NotifyType> watches) throws Exception {
+      owner = testAccount("owner");
+      reviewer = testAccount("reviewer");
+      author = testAccount("author");
+      uploader = testAccount("uploader");
+      ccer = testAccount("ccer");
+      starrer = testAccount("starrer");
+
+      watchingProjectOwner = testAccount("watchingProjectOwner", "Administrators");
+      setApiUser(watchingProjectOwner);
+      watch(project.get(), pwi -> pwi.notifyNewChanges = true);
+
+      for (NotifyType watch : watches) {
+        TestAccount watcher = testAccount(watch.toString());
+        setApiUser(watcher);
+        watch(
+            project.get(),
+            pwi -> {
+              pwi.notifyAllComments = watch.equals(NotifyType.ALL_COMMENTS);
+              pwi.notifyAbandonedChanges = watch.equals(NotifyType.ABANDONED_CHANGES);
+              pwi.notifyNewChanges = watch.equals(NotifyType.NEW_CHANGES);
+              pwi.notifyNewPatchSets = watch.equals(NotifyType.NEW_PATCHSETS);
+              pwi.notifySubmittedChanges = watch.equals(NotifyType.SUBMITTED_CHANGES);
+            });
+        watchers.put(watch, watcher);
+      }
+    }
+
+    private String email(String username) {
+      // Email validator rejects usernames longer than 64 bytes.
+      if (username.length() > 64) {
+        username = username.substring(username.length() - 64);
+        if (username.startsWith(".")) {
+          username = username.substring(1);
+        }
+      }
+      return username + "@example.com";
+    }
+
+    TestAccount testAccount(String name) throws Exception {
+      String username = name(name);
+      TestAccount account = accountCreator.create(username, email(username), name);
+      accountsByEmail.put(account.email, account);
+      return account;
+    }
+
+    TestAccount testAccount(String name, String groupName) throws Exception {
+      String username = name(name);
+      TestAccount account = accountCreator.create(username, email(username), name, groupName);
+      accountsByEmail.put(account.email, account);
+      return account;
+    }
+
+    String emailToName(String email) {
+      if (accountsByEmail.containsKey(email)) {
+        return accountsByEmail.get(email).fullName;
+      }
+      return email;
+    }
+
+    protected void addReviewers(PushOneCommit.Result r) throws Exception {
+      AddReviewerInput in = new AddReviewerInput();
+      in.reviewer = reviewer.email;
+      gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+      in.reviewer = ccer.email;
+      in.state = ReviewerState.CC;
+      gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+      in.reviewer = reviewerByEmail;
+      in.state = ReviewerState.REVIEWER;
+      AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(in);
+      if (result.reviewers == null || result.reviewers.isEmpty()) {
+        supportReviewersByEmail = false;
+      } else {
+        supportReviewersByEmail = true;
+        in.reviewer = ccerByEmail;
+        in.state = ReviewerState.CC;
+        gApi.changes().id(r.getChangeId()).addReviewer(in);
+      }
+    }
+  }
+
+  protected interface PushOptionGenerator {
+    List<String> pushOptions(StagedUsers users);
+  }
+
+  protected class StagedPreChange extends StagedUsers {
+    public final TestRepository<?> repo;
+    protected final PushOneCommit.Result result;
+    public final String changeId;
+
+    StagedPreChange(String ref, List<NotifyType> watches) throws Exception {
+      this(ref, null, watches);
+    }
+
+    StagedPreChange(
+        String ref, @Nullable PushOptionGenerator pushOptionGenerator, List<NotifyType> watches)
+        throws Exception {
+      super(watches);
+      List<String> pushOptions = null;
+      if (pushOptionGenerator != null) {
+        pushOptions = pushOptionGenerator.pushOptions(this);
+      }
+      if (pushOptions != null) {
+        ref = ref + '%' + Joiner.on(',').join(pushOptions);
+      }
+      repo = cloneProject(project, owner);
+      PushOneCommit push = pushFactory.create(db, owner.getIdent(), repo);
+      result = push.to(ref);
+      result.assertOkStatus();
+      changeId = result.getChangeId();
+    }
+  }
+
+  protected StagedPreChange stagePreChange(String ref, NotifyType... watches) throws Exception {
+    return new StagedPreChange(ref, ImmutableList.copyOf(watches));
+  }
+
+  protected StagedPreChange stagePreChange(
+      String ref, @Nullable PushOptionGenerator pushOptionGenerator, NotifyType... watches)
+      throws Exception {
+    return new StagedPreChange(ref, pushOptionGenerator, ImmutableList.copyOf(watches));
+  }
+
+  protected class StagedChange extends StagedPreChange {
+
+    StagedChange(String ref, List<NotifyType> watches) throws Exception {
+      super(ref, watches);
+
+      setApiUser(starrer);
+      gApi.accounts().self().starChange(result.getChangeId());
+
+      setApiUser(owner);
+      addReviewers(result);
+      sender.clear();
+    }
+  }
+
+  protected StagedChange stageReviewableChange(NotifyType... watches) throws Exception {
+    return new StagedChange("refs/for/master", ImmutableList.copyOf(watches));
+  }
+
+  protected StagedChange stageWipChange(NotifyType... watches) throws Exception {
+    return new StagedChange("refs/for/master%wip", ImmutableList.copyOf(watches));
+  }
+
+  protected StagedChange stageReviewableWipChange(NotifyType... watches) throws Exception {
+    StagedChange sc = stageReviewableChange(watches);
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).setWorkInProgress();
+    return sc;
+  }
+
+  protected StagedChange stageAbandonedReviewableChange(NotifyType... watches) throws Exception {
+    StagedChange sc = stageReviewableChange(watches);
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).abandon();
+    sender.clear();
+    return sc;
+  }
+
+  protected StagedChange stageAbandonedReviewableWipChange(NotifyType... watches) throws Exception {
+    StagedChange sc = stageReviewableWipChange(watches);
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).abandon();
+    sender.clear();
+    return sc;
+  }
+
+  protected StagedChange stageAbandonedWipChange(NotifyType... watches) throws Exception {
+    StagedChange sc = stageWipChange(watches);
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).abandon();
+    sender.clear();
+    return sc;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index afd20bb..43d02a6 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -113,7 +113,7 @@
   @Test
   public void notificationsOnChangeCreation() throws Exception {
     setApiUser(user);
-    watch(project.get(), null);
+    watch(project.get());
 
     // check that watcher is notified
     setApiUser(admin);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 140a756..e7fe81f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -285,7 +285,7 @@
     // watch project
     String watchedProject = createProject("watchedProject").get();
     setApiUser(user);
-    watch(watchedProject, null);
+    watch(watchedProject);
 
     // push a change to watched project -> should trigger email notification
     setApiUser(admin);
@@ -327,7 +327,7 @@
     watch(watchedProject, "file:a.txt");
 
     // watch other project as user
-    watch(otherWatchedProject, null);
+    watch(otherWatchedProject);
 
     // push a change to watched file -> should trigger email notification for
     // user
@@ -352,7 +352,7 @@
     // watch project as user2
     TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2");
     setApiUser(user2);
-    watch(watchedProject, null);
+    watch(watchedProject);
 
     // push a change to non-watched file -> should not trigger email
     // notification for user, only for user2
@@ -416,7 +416,7 @@
     setApiUser(user);
 
     // watch the All-Projects project to watch all projects
-    watch(allProjects.get(), null);
+    watch(allProjects.get());
 
     // push a change to any project -> should trigger email notification
     setApiUser(admin);
@@ -469,7 +469,7 @@
     // watch project as user2
     TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2");
     setApiUser(user2);
-    watch(anyProject, null);
+    watch(anyProject);
 
     // push a change to non-watched file in any project -> should not trigger
     // email notification for user, only for user2
@@ -533,7 +533,7 @@
     // watch project
     String watchedProject = createProject("watchedProject").get();
     setApiUser(user);
-    watch(watchedProject, null);
+    watch(watchedProject);
 
     // push a draft change to watched project -> should not trigger email notification
     setApiUser(admin);
@@ -564,13 +564,13 @@
 
     // watch project as user that can't view drafts
     setApiUser(user);
-    watch(watchedProject, null);
+    watch(watchedProject);
 
     // watch project as user that can view all drafts
     TestAccount userThatCanViewDrafts =
         accountCreator.create("user2", "user2@test.com", "User2", groupThatCanViewDrafts.name);
     setApiUser(userThatCanViewDrafts);
-    watch(watchedProject, null);
+    watch(watchedProject);
 
     // push a draft change to watched project -> should trigger email notification for
     // userThatCanViewDrafts, but not for user
@@ -597,7 +597,7 @@
     // watch project
     String watchedProject = createProject("watchedProject").get();
     setApiUser(user);
-    watch(watchedProject, null);
+    watch(watchedProject);
 
     // push a change to watched project
     setApiUser(admin);
@@ -658,7 +658,7 @@
     // watch project
     String watchedProject = createProject("watchedProject").get();
     setApiUser(user);
-    watch(watchedProject, null);
+    watch(watchedProject);
 
     // push a private change to watched project -> should not trigger email notification
     setApiUser(admin);
@@ -690,14 +690,14 @@
 
     // watch project as user that can't view private changes
     setApiUser(user);
-    watch(watchedProject, null);
+    watch(watchedProject);
 
     // watch project as user that can view all private change
     TestAccount userThatCanViewPrivateChanges =
         accountCreator.create(
             "user2", "user2@test.com", "User2", groupThatCanViewPrivateChanges.name);
     setApiUser(userThatCanViewPrivateChanges);
-    watch(watchedProject, null);
+    watch(watchedProject);
 
     // push a private change to watched project -> should trigger email notification for
     // userThatCanViewPrivateChanges, but not for user
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
index ed3e41f..9ab1d6e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
@@ -28,11 +28,7 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 import java.util.concurrent.ExecutionException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -82,11 +78,13 @@
 
   private final WorkQueue workQueue;
   private final List<Message> messages;
+  private int messagesRead;
 
   @Inject
   FakeEmailSender(WorkQueue workQueue) {
     this.workQueue = workQueue;
     messages = Collections.synchronizedList(new ArrayList<Message>());
+    messagesRead = 0;
   }
 
   @Override
@@ -121,9 +119,23 @@
     waitForEmails();
     synchronized (messages) {
       messages.clear();
+      messagesRead = 0;
     }
   }
 
+  public synchronized @Nullable Message peekMessage() {
+    if (messagesRead >= messages.size()) {
+      return null;
+    }
+    return messages.get(messagesRead);
+  }
+
+  public synchronized @Nullable Message nextMessage() {
+    Message msg = peekMessage();
+    messagesRead++;
+    return msg;
+  }
+
   public ImmutableList<Message> getMessages() {
     waitForEmails();
     synchronized (messages) {