Merge "Always request and show `submittable` info"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index d9ab64d..5a2fc2e 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -384,6 +384,13 @@
 * `CUSTOM_KEYED_VALUES`: include the custom key-value map
 ---
 
+[[star]]
+--
+* `STAR`: include the `starred` field in
+   link:#change-info[ChangeInfo], which indicates if the change is starred
+   by the current user or not.
+--
+
 .Request
 ----
   GET /changes/?q=97&o=CURRENT_REVISION&o=CURRENT_COMMIT&o=CURRENT_FILES&o=DOWNLOAD_COMMANDS HTTP/1.0
@@ -6972,6 +6979,7 @@
 link:rest-api-accounts.html#account-info[ AccountInfo] entity.
 |`starred`            |not set if `false`|
 Whether the calling user has starred this change with the default label.
+Only set if link:#star[requested].
 |`stars`              |optional|
 A list of star labels that are applied by the calling user to this
 change. The labels are lexicographically sorted.
diff --git a/java/com/google/gerrit/extensions/client/ListChangesOption.java b/java/com/google/gerrit/extensions/client/ListChangesOption.java
index de4326e..e2a7c1e 100644
--- a/java/com/google/gerrit/extensions/client/ListChangesOption.java
+++ b/java/com/google/gerrit/extensions/client/ListChangesOption.java
@@ -91,7 +91,10 @@
   SUBMIT_REQUIREMENTS(24),
 
   /** Include custom keyed values. */
-  CUSTOM_KEYED_VALUES(25);
+  CUSTOM_KEYED_VALUES(25),
+
+  /** Include the 'starred' field, that is if the change is starred by the current user . */
+  STAR(26);
 
   private final int value;
 
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index bcc8631..f709dd6 100644
--- a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -34,7 +34,7 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.EmailModule.DeleteKeyEmailFactories;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -53,7 +53,7 @@
   private final Provider<PublicKeyStore> storeProvider;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final ExternalIds externalIds;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final DeleteKeyEmailFactories deleteKeyEmailFactories;
   private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
@@ -62,13 +62,13 @@
       Provider<PublicKeyStore> storeProvider,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
       ExternalIds externalIds,
-      DeleteKeySender.Factory deleteKeySenderFactory,
+      DeleteKeyEmailFactories deleteKeyEmailFactories,
       ExternalIdKeyFactory externalIdKeyFactory) {
     this.serverIdent = serverIdent;
     this.storeProvider = storeProvider;
     this.accountsUpdateProvider = accountsUpdateProvider;
     this.externalIds = externalIds;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.deleteKeyEmailFactories = deleteKeyEmailFactories;
     this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
@@ -104,8 +104,8 @@
         case NO_CHANGE:
         case FAST_FORWARD:
           try {
-            deleteKeySenderFactory
-                .create(rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key)))
+            deleteKeyEmailFactories
+                .createEmail(rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key)))
                 .send();
           } catch (EmailException e) {
             logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index a45c400..edd5a58 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -58,8 +58,8 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.EmailModule.AddKeyEmailFactories;
+import com.google.gerrit.server.mail.EmailModule.DeleteKeyEmailFactories;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
@@ -91,8 +91,8 @@
   private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
-  private final AddKeySender.Factory addKeySenderFactory;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final AddKeyEmailFactories addKeyEmailFactories;
+  private final DeleteKeyEmailFactories deleteKeyEmailFactories;
   private final Provider<InternalAccountQuery> accountQueryProvider;
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
@@ -106,8 +106,8 @@
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
       GerritPublicKeyChecker.Factory checkerFactory,
-      AddKeySender.Factory addKeySenderFactory,
-      DeleteKeySender.Factory deleteKeySenderFactory,
+      AddKeyEmailFactories addKeyEmailFactories,
+      DeleteKeyEmailFactories deleteKeyEmailFactories,
       Provider<InternalAccountQuery> accountQueryProvider,
       ExternalIds externalIds,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
@@ -118,8 +118,8 @@
     this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
-    this.addKeySenderFactory = addKeySenderFactory;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.addKeyEmailFactories = addKeyEmailFactories;
+    this.deleteKeyEmailFactories = deleteKeyEmailFactories;
     this.accountQueryProvider = accountQueryProvider;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
@@ -263,7 +263,7 @@
         case FORCED:
           if (!addedKeys.isEmpty()) {
             try {
-              addKeySenderFactory.create(user, addedKeys).send();
+              addKeyEmailFactories.createEmail(user, addedKeys).send();
             } catch (EmailException e) {
               logger.atSevere().withCause(e).log(
                   "Cannot send GPG key added message to %s",
@@ -272,8 +272,8 @@
           }
           if (!toRemove.isEmpty()) {
             try {
-              deleteKeySenderFactory
-                  .create(user, toRemove.stream().map(Fingerprint::toString).collect(toList()))
+              deleteKeyEmailFactories
+                  .createEmail(user, toRemove.stream().map(Fingerprint::toString).collect(toList()))
                   .send();
             } catch (EmailException e) {
               logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 210ba7b..9056732 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -1615,7 +1615,9 @@
   private void checkUserSession(HttpServletRequest req) throws AuthException {
     CurrentUser user = globals.currentUser.get();
     if (isRead(req)) {
-      user.setAccessPath(AccessPath.REST_API);
+      if (user.getAccessPath().equals(AccessPath.UNKNOWN)) {
+        user.setAccessPath(AccessPath.REST_API);
+      }
     } else if (user instanceof AnonymousUser) {
       throw new AuthException("Authentication required");
     } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) {
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index a057e66..d0d03b5 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -122,6 +122,8 @@
     extractMailExample("DeleteReviewerHtml.soy");
     extractMailExample("DeleteVote.soy");
     extractMailExample("DeleteVoteHtml.soy");
+    extractMailExample("Email.soy");
+    extractMailExample("EmailHtml.soy");
     extractMailExample("Footer.soy");
     extractMailExample("FooterHtml.soy");
     extractMailExample("ChangeHeader.soy");
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 2d18054..cf04029 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -49,10 +49,12 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.List;
 import java.util.NavigableSet;
 import java.util.Objects;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.NullProgressMonitor;
@@ -217,6 +219,29 @@
   }
 
   /**
+   * Returns a subset of change IDs among the input {@code changeIds} list that are starred by the
+   * {@code caller} user.
+   */
+  public Set<Change.Id> areStarred(
+      Repository allUsersRepo, List<Change.Id> changeIds, Account.Id caller) {
+    List<String> starRefs =
+        changeIds.stream()
+            .map(c -> RefNames.refsStarredChanges(c, caller))
+            .collect(Collectors.toList());
+    try {
+      return allUsersRepo.getRefDatabase().exactRef(starRefs.toArray(new String[0])).keySet()
+          .stream()
+          .map(r -> Change.Id.fromAllUsersRef(r))
+          .collect(Collectors.toSet());
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log(
+          "Failed getting starred changes for account %d within changes: %s",
+          caller.get(), Joiner.on(", ").join(changeIds));
+      return ImmutableSet.of();
+    }
+  }
+
+  /**
    * Unstar the given change for all users.
    *
    * <p>Intended for use only when we're about to delete a change. For that reason, the change is
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index 14216b3..621994d 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -26,9 +26,9 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.extensions.events.ChangeAbandoned;
 import com.google.gerrit.server.mail.EmailModule.AbandonedChangeEmailFactories;
-import com.google.gerrit.server.mail.send.ChangeEmailNew;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.OutgoingEmailNew;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -112,10 +112,10 @@
   public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     try {
-      ChangeEmailNew changeEmail =
+      ChangeEmail changeEmail =
           abandonedChangeEmailFactories.createChangeEmail(ctx.getProject(), change.getId());
       changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
-      OutgoingEmailNew email = abandonedChangeEmailFactories.createEmail(changeEmail);
+      OutgoingEmail email = abandonedChangeEmailFactories.createEmail(changeEmail);
       if (accountState != null) {
         email.setFrom(accountState.account().id());
       }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 8773bb7..4273a72 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -63,8 +63,11 @@
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.mail.EmailModule.StartReviewChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
@@ -110,7 +113,7 @@
   private final PatchSetUtil psUtil;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final CreateChangeSender.Factory createChangeSenderFactory;
+  private final StartReviewChangeEmailFactories startReviewChangeEmailFactories;
   private final ExecutorService sendEmailExecutor;
   private final CommitValidators.Factory commitValidatorsFactory;
   private final RevisionCreated revisionCreated;
@@ -162,7 +165,7 @@
       PatchSetUtil psUtil,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      CreateChangeSender.Factory createChangeSenderFactory,
+      StartReviewChangeEmailFactories startReviewChangeEmailFactories,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       CommitValidators.Factory commitValidatorsFactory,
       CommentAdded commentAdded,
@@ -180,7 +183,7 @@
     this.psUtil = psUtil;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.createChangeSenderFactory = createChangeSenderFactory;
+    this.startReviewChangeEmailFactories = startReviewChangeEmailFactories;
     this.sendEmailExecutor = sendEmailExecutor;
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.revisionCreated = revisionCreated;
@@ -531,24 +534,30 @@
             @Override
             public void run() {
               try {
-                CreateChangeSender emailSender =
-                    createChangeSenderFactory.create(change.getProject(), change.getId());
-                emailSender.setFrom(change.getOwner());
-                emailSender.setPatchSet(patchSet, patchSetInfo);
-                emailSender.setNotify(notify);
-                emailSender.addReviewers(
+                StartReviewChangeEmailDecorator startReviewEmail =
+                    startReviewChangeEmailFactories.createStartReviewChangeEmail();
+                startReviewEmail.markAsCreateChange();
+                startReviewEmail.addReviewers(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
                         .map(PatchSetApproval::accountId)
                         .collect(toImmutableSet()));
-                emailSender.addReviewersByEmail(
+                startReviewEmail.addReviewersByEmail(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewersByEmail));
-                emailSender.addExtraCC(
+                startReviewEmail.addExtraCC(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs));
-                emailSender.addExtraCCByEmail(
+                startReviewEmail.addExtraCCByEmail(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCsByEmail));
-                emailSender.setMessageId(
+                ChangeEmail changeEmail =
+                    startReviewChangeEmailFactories.createChangeEmail(
+                        change.getProject(), change.getId(), startReviewEmail);
+                changeEmail.setPatchSet(patchSet, patchSetInfo);
+                OutgoingEmail outgoingEmail =
+                    startReviewChangeEmailFactories.createEmail(changeEmail);
+                outgoingEmail.setFrom(change.getOwner());
+                outgoingEmail.setNotify(notify);
+                outgoingEmail.setMessageId(
                     messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-                emailSender.send();
+                outgoingEmail.send();
               } catch (Exception e) {
                 logger.atSevere().withCause(e).log(
                     "Cannot send email for new change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 839629e..4875978 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -31,6 +31,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_DIFFSTAT;
+import static com.google.gerrit.extensions.client.ListChangesOption.STAR;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMIT_REQUIREMENTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
@@ -100,10 +101,12 @@
 import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -133,6 +136,7 @@
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 
 /**
  * Produces {@link ChangeInfo} (which is serialized to JSON afterwards) from {@link ChangeData}.
@@ -222,12 +226,15 @@
     }
   }
 
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
   private final Provider<CurrentUser> userProvider;
   private final PermissionBackend permissionBackend;
   private final ChangeData.Factory changeDataFactory;
   private final AccountLoader.Factory accountLoaderFactory;
   private final ImmutableSet<ListChangesOption> options;
   private final ChangeMessagesUtil cmUtil;
+  private final StarredChangesUtil starredChangesUtil;
   private final Provider<ConsistencyChecker> checkerProvider;
   private final ActionJson actionJson;
   private final ChangeNotes.Factory notesFactory;
@@ -247,12 +254,15 @@
 
   @Inject
   ChangeJson(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
       ExperimentFeatures experimentFeatures,
       Provider<CurrentUser> user,
       PermissionBackend permissionBackend,
       ChangeData.Factory cdf,
       AccountLoader.Factory ailf,
       ChangeMessagesUtil cmUtil,
+      StarredChangesUtil starredChangesUtil,
       Provider<ConsistencyChecker> checkerProvider,
       ActionJson actionJson,
       ChangeNotes.Factory notesFactory,
@@ -264,12 +274,15 @@
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
       @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
     this.experimentFeatures = experimentFeatures;
     this.userProvider = user;
     this.changeDataFactory = cdf;
     this.permissionBackend = permissionBackend;
     this.accountLoaderFactory = ailf;
     this.cmUtil = cmUtil;
+    this.starredChangesUtil = starredChangesUtil;
     this.checkerProvider = checkerProvider;
     this.actionJson = actionJson;
     this.notesFactory = notesFactory;
@@ -539,6 +552,9 @@
               "Omitting corrupt change %s from results", cd.getId());
         }
       }
+      if (has(STAR)) {
+        populateStarField(changeInfos);
+      }
       return changeInfos;
     }
   }
@@ -956,6 +972,25 @@
     return map.build();
   }
 
+  /** Populate the 'starred' field. */
+  private void populateStarField(List<ChangeInfo> changeInfos) {
+    // We populate the 'starred' field for all change infos together so that we open the All-Users
+    // repository only once
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      List<Change.Id> changeIds =
+          changeInfos.stream().map(c -> Change.id(c._number)).collect(Collectors.toList());
+      Set<Change.Id> starredChanges =
+          starredChangesUtil.areStarred(
+              allUsersRepo, changeIds, userProvider.get().asIdentifiedUser().getAccountId());
+      if (starredChanges.isEmpty()) {
+        return;
+      }
+      changeInfos.stream().forEach(c -> c.starred = starredChanges.contains(Change.id(c._number)));
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log("Failed to open All-Users repo.");
+    }
+  }
+
   private List<PluginDefinedInfo> getPluginInfos(ChangeData cd) {
     return getPluginInfos(Collections.singleton(cd)).get(cd.getId());
   }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index 2030ef2..294049f 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -20,10 +20,10 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.mail.EmailModule.DeleteReviewerChangeEmailFactories;
-import com.google.gerrit.server.mail.send.ChangeEmailNew;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.OutgoingEmailNew;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
@@ -82,12 +82,11 @@
         DeleteReviewerChangeEmailDecorator deleteReviewerEmail =
             deleteReviewerChangeEmailFactories.createDeleteReviewerChangeEmail();
         deleteReviewerEmail.addReviewersByEmail(Collections.singleton(reviewer));
-        ChangeEmailNew changeEmail =
+        ChangeEmail changeEmail =
             deleteReviewerChangeEmailFactories.createChangeEmail(
                 ctx.getProject(), change.getId(), deleteReviewerEmail);
         changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
-        OutgoingEmailNew outgoingEmail =
-            deleteReviewerChangeEmailFactories.createEmail(changeEmail);
+        OutgoingEmail outgoingEmail = deleteReviewerChangeEmailFactories.createEmail(changeEmail);
         outgoingEmail.setFrom(ctx.getAccountId());
         outgoingEmail.setNotify(notify);
         outgoingEmail.setMessageId(
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index a1c4f71..f961d61 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -37,10 +37,10 @@
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
 import com.google.gerrit.server.mail.EmailModule.DeleteReviewerChangeEmailFactories;
-import com.google.gerrit.server.mail.send.ChangeEmailNew;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.OutgoingEmailNew;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -256,11 +256,11 @@
     DeleteReviewerChangeEmailDecorator deleteReviewerEmail =
         deleteReviewerChangeEmailFactories.createDeleteReviewerChangeEmail();
     deleteReviewerEmail.addReviewers(Collections.singleton(reviewer.id()));
-    ChangeEmailNew changeEmail =
+    ChangeEmail changeEmail =
         deleteReviewerChangeEmailFactories.createChangeEmail(
             projectName, change.getId(), deleteReviewerEmail);
     changeEmail.setChangeMessage(mailMessage, timestamp.toInstant());
-    OutgoingEmailNew outgoingEmail = deleteReviewerChangeEmailFactories.createEmail(changeEmail);
+    OutgoingEmail outgoingEmail = deleteReviewerChangeEmailFactories.createEmail(changeEmail);
     outgoingEmail.setFrom(userId);
     outgoingEmail.setNotify(notify);
     outgoingEmail.setMessageId(
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
index fedaad2..f695a56 100644
--- a/java/com/google/gerrit/server/change/EmailNewPatchSet.java
+++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -29,10 +29,10 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.EmailModule.ReplacePatchSetChangeEmailFactories;
-import com.google.gerrit.server.mail.send.ChangeEmailNew;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
-import com.google.gerrit.server.mail.send.OutgoingEmailNew;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.mail.send.ReplacePatchSetChangeEmailDecorator;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.PostUpdateContext;
@@ -221,13 +221,12 @@
         replacePatchSetEmail.addReviewers(reviewers);
         replacePatchSetEmail.addExtraCC(extraCcs);
         replacePatchSetEmail.addOutdatedApproval(outdatedApprovals);
-        ChangeEmailNew changeEmail =
+        ChangeEmail changeEmail =
             replacePatchSetChangeEmailFactories.createChangeEmail(
                 projectName, changeId, replacePatchSetEmail);
         changeEmail.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
         changeEmail.setChangeMessage(message, timestamp);
-        OutgoingEmailNew outgoingEmail =
-            replacePatchSetChangeEmailFactories.createEmail(changeEmail);
+        OutgoingEmail outgoingEmail = replacePatchSetChangeEmailFactories.createEmail(changeEmail);
         outgoingEmail.setFrom(user.getAccountId());
         outgoingEmail.setNotify(notify);
         outgoingEmail.setMessageId(messageId);
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index a1201ed..67e09b0 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -29,11 +29,11 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.EmailModule.CommentChangeEmailFactories;
-import com.google.gerrit.server.mail.send.ChangeEmailNew;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.CommentChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
-import com.google.gerrit.server.mail.send.OutgoingEmailNew;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.LabelVote;
@@ -214,12 +214,12 @@
         commentChangeEmail.setComments(comments);
         commentChangeEmail.setPatchSetComment(patchSetComment);
         commentChangeEmail.setLabels(labels);
-        ChangeEmailNew changeEmail =
+        ChangeEmail changeEmail =
             commentChangeEmailFactories.createChangeEmail(
                 projectName, changeId, commentChangeEmail);
         changeEmail.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
         changeEmail.setChangeMessage(message, timestamp);
-        OutgoingEmailNew outgoingEmail = commentChangeEmailFactories.createEmail(changeEmail);
+        OutgoingEmail outgoingEmail = commentChangeEmailFactories.createEmail(changeEmail);
         outgoingEmail.setFrom(user.getAccountId());
         outgoingEmail.setNotify(notify);
         outgoingEmail.setMessageId(messageId);
diff --git a/java/com/google/gerrit/server/change/ModifyReviewersEmail.java b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
index cb747f6..5f2a5fd 100644
--- a/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
@@ -24,8 +24,11 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.EmailModule.StartReviewChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ModifyReviewerSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -36,16 +39,16 @@
 public class ModifyReviewersEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final ModifyReviewerSender.Factory addReviewerSenderFactory;
+  private final StartReviewChangeEmailFactories startReviewChangeEmailFactories;
   private final ExecutorService sendEmailsExecutor;
   private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   ModifyReviewersEmail(
-      ModifyReviewerSender.Factory addReviewerSenderFactory,
+      StartReviewChangeEmailFactories startReviewChangeEmailFactories,
       @SendEmailExecutor ExecutorService sendEmailsExecutor,
       MessageIdGenerator messageIdGenerator) {
-    this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.startReviewChangeEmailFactories = startReviewChangeEmailFactories;
     this.sendEmailsExecutor = sendEmailsExecutor;
     this.messageIdGenerator = messageIdGenerator;
   }
@@ -90,20 +93,25 @@
         sendEmailsExecutor.submit(
             () -> {
               try {
-                ModifyReviewerSender emailSender =
-                    addReviewerSenderFactory.create(projectNameKey, cId);
-                emailSender.setNotify(notify);
-                emailSender.setFrom(userId);
-                emailSender.addReviewers(immutableToMail);
-                emailSender.addReviewersByEmail(immutableAddedByEmail);
-                emailSender.addExtraCC(immutableToCopy);
-                emailSender.addExtraCCByEmail(immutableCopiedByEmail);
-                emailSender.addRemovedReviewers(immutableToRemove);
-                emailSender.addRemovedByEmailReviewers(immutableRemovedByEmail);
-                emailSender.setMessageId(
+                StartReviewChangeEmailDecorator startReviewEmail =
+                    startReviewChangeEmailFactories.createStartReviewChangeEmail();
+                startReviewEmail.addReviewers(immutableToMail);
+                startReviewEmail.addReviewersByEmail(immutableAddedByEmail);
+                startReviewEmail.addExtraCC(immutableToCopy);
+                startReviewEmail.addExtraCCByEmail(immutableCopiedByEmail);
+                startReviewEmail.addRemovedReviewers(immutableToRemove);
+                startReviewEmail.addRemovedByEmailReviewers(immutableRemovedByEmail);
+                ChangeEmail changeEmail =
+                    startReviewChangeEmailFactories.createChangeEmail(
+                        projectNameKey, cId, startReviewEmail);
+                OutgoingEmail outgoingEmail =
+                    startReviewChangeEmailFactories.createEmail(changeEmail);
+                outgoingEmail.setNotify(notify);
+                outgoingEmail.setFrom(userId);
+                outgoingEmail.setMessageId(
                     messageIdGenerator.fromChangeUpdate(
                         change.getProject(), change.currentPatchSetId()));
-                emailSender.send();
+                outgoingEmail.send();
               } catch (Exception err) {
                 logger.atSevere().withCause(err).log(
                     "Cannot send email to new reviewers of change %s", change.getId());
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index ea2465b..1d85fc6 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -169,7 +169,6 @@
 import com.google.gerrit.server.mail.MailFilter;
 import com.google.gerrit.server.mail.send.FromAddressGenerator;
 import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
-import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
 import com.google.gerrit.server.mail.send.MailSoySauceModule;
 import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
 import com.google.gerrit.server.mime.FileTypeRegistry;
@@ -305,7 +304,6 @@
     factory(PatchScriptFactoryForAutoFix.Factory.class);
     factory(ProjectState.Factory.class);
     factory(RevisionJson.Factory.class);
-    factory(InboundEmailRejectionSender.Factory.class);
     factory(ExternalUser.Factory.class);
     bind(PermissionCollection.Factory.class);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 6913b1e..40d714d 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -44,9 +44,9 @@
 import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.mail.EmailModule.RevertedChangeEmailFactories;
-import com.google.gerrit.server.mail.send.ChangeEmailNew;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.OutgoingEmailNew;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.Sequences;
@@ -383,10 +383,10 @@
           ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertingChangeId));
       changeReverted.fire(revertedChange, revertingChange, ctx.getWhen());
       try {
-        ChangeEmailNew changeEmail =
+        ChangeEmail changeEmail =
             revertedChangeEmailFactories.createChangeEmail(
                 ctx.getProject(), revertedChange.getId());
-        OutgoingEmailNew outgoingEmail = revertedChangeEmailFactories.createEmail(changeEmail);
+        OutgoingEmail outgoingEmail = revertedChangeEmailFactories.createEmail(changeEmail);
         outgoingEmail.setFrom(ctx.getAccountId());
         outgoingEmail.setNotify(ctx.getNotify(revertedChangeId));
         outgoingEmail.setMessageId(
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index cb46df1..8844c1e 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -27,9 +27,9 @@
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
 import com.google.gerrit.server.mail.EmailModule.MergedChangeEmailFactories;
-import com.google.gerrit.server.mail.send.ChangeEmailNew;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.OutgoingEmailNew;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -190,13 +190,13 @@
                     try {
                       // The stickyApprovalDiff is always empty here since this is not supported
                       // for direct pushes.
-                      ChangeEmailNew changeEmail =
+                      ChangeEmail changeEmail =
                           mergedChangeEmailFactories.createChangeEmail(
                               ctx.getProject(),
                               psId.changeId(),
                               /* stickyApprovalDiff= */ Optional.empty());
                       changeEmail.setPatchSet(patchSet, info);
-                      OutgoingEmailNew outgoingEmail =
+                      OutgoingEmail outgoingEmail =
                           mergedChangeEmailFactories.createEmail(changeEmail);
                       outgoingEmail.setFrom(ctx.getAccountId());
                       outgoingEmail.setMessageId(
diff --git a/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
index 355dd5f..340a103 100644
--- a/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/java/com/google/gerrit/server/mail/EmailModule.java
@@ -14,62 +14,58 @@
 
 package com.google.gerrit.server.mail;
 
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.mail.send.AbandonedChangeEmailDecorator;
-import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.mail.send.AddKeyEmailDecoratorFactory;
 import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange;
-import com.google.gerrit.server.mail.send.ChangeEmailNew;
-import com.google.gerrit.server.mail.send.ChangeEmailNewFactory;
+import com.google.gerrit.server.mail.send.ChangeEmail;
+import com.google.gerrit.server.mail.send.ChangeEmailFactory;
 import com.google.gerrit.server.mail.send.CommentChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.CommentChangeEmailDecoratorFactory;
-import com.google.gerrit.server.mail.send.CreateChangeSender;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.send.DeleteKeyEmailDecoratorFactory;
 import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.DeleteVoteChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.EmailArguments;
-import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
+import com.google.gerrit.server.mail.send.HttpPasswordUpdateEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator.InboundEmailError;
 import com.google.gerrit.server.mail.send.MergedChangeEmailDecoratorFactory;
-import com.google.gerrit.server.mail.send.ModifyReviewerSender;
-import com.google.gerrit.server.mail.send.OutgoingEmailNew;
-import com.google.gerrit.server.mail.send.OutgoingEmailNewFactory;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.OutgoingEmailFactory;
+import com.google.gerrit.server.mail.send.RegisterNewEmailDecorator;
+import com.google.gerrit.server.mail.send.RegisterNewEmailDecoratorFactory;
 import com.google.gerrit.server.mail.send.ReplacePatchSetChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.ReplacePatchSetChangeEmailDecoratorFactory;
 import com.google.gerrit.server.mail.send.RestoredChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.RevertedChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
 import com.google.inject.Inject;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 
 public class EmailModule extends FactoryModule {
-  @Override
-  protected void configure() {
-    factory(AddKeySender.Factory.class);
-    factory(ModifyReviewerSender.Factory.class);
-    factory(CreateChangeSender.Factory.class);
-    factory(DeleteKeySender.Factory.class);
-    factory(HttpPasswordUpdateSender.Factory.class);
-    factory(RegisterNewEmailSender.Factory.class);
-  }
-
   public static class AbandonedChangeEmailFactories {
     private final EmailArguments args;
-    private final ChangeEmailNewFactory changeEmailFactory;
-    private final OutgoingEmailNewFactory outgoingEmailFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
     private final AbandonedChangeEmailDecorator abandonedChangeEmailDecorator;
 
     @Inject
     AbandonedChangeEmailFactories(
         EmailArguments args,
-        ChangeEmailNewFactory changeEmailFactory,
-        OutgoingEmailNewFactory outgoingEmailFactory,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory,
         AbandonedChangeEmailDecorator abandonedChangeEmailDecorator) {
       this.args = args;
       this.changeEmailFactory = changeEmailFactory;
@@ -77,26 +73,26 @@
       this.abandonedChangeEmailDecorator = abandonedChangeEmailDecorator;
     }
 
-    public ChangeEmailNew createChangeEmail(Project.NameKey project, Change.Id changeId) {
+    public ChangeEmail createChangeEmail(Project.NameKey project, Change.Id changeId) {
       return changeEmailFactory.create(
           args.newChangeData(project, changeId), abandonedChangeEmailDecorator);
     }
 
-    public OutgoingEmailNew createEmail(ChangeEmailNew changeEmail) {
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
       return outgoingEmailFactory.create("abandon", changeEmail);
     }
   }
 
   public static class AttentionSetChangeEmailFactories {
     private final EmailArguments args;
-    private final ChangeEmailNewFactory changeEmailFactory;
-    private final OutgoingEmailNewFactory outgoingEmailFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
 
     @Inject
     AttentionSetChangeEmailFactories(
         EmailArguments args,
-        ChangeEmailNewFactory changeEmailFactory,
-        OutgoingEmailNewFactory outgoingEmailFactory) {
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
       this.args = args;
       this.changeEmailFactory = changeEmailFactory;
       this.outgoingEmailFactory = outgoingEmailFactory;
@@ -106,7 +102,7 @@
       return new AttentionSetChangeEmailDecorator();
     }
 
-    public ChangeEmailNew createChangeEmail(
+    public ChangeEmail createChangeEmail(
         Project.NameKey project,
         Change.Id changeId,
         AttentionSetChangeEmailDecorator attentionSetChangeEmailDecorator) {
@@ -114,8 +110,8 @@
           args.newChangeData(project, changeId), attentionSetChangeEmailDecorator);
     }
 
-    public OutgoingEmailNew createEmail(
-        AttentionSetChange attentionSetChange, ChangeEmailNew changeEmail) {
+    public OutgoingEmail createEmail(
+        AttentionSetChange attentionSetChange, ChangeEmail changeEmail) {
       if (attentionSetChange.equals(AttentionSetChange.USER_ADDED)) {
         return outgoingEmailFactory.create("addToAttentionSet", changeEmail);
       } else {
@@ -127,15 +123,15 @@
   public static class CommentChangeEmailFactories {
     private final EmailArguments args;
     private final CommentChangeEmailDecoratorFactory commentChangeEmailFactory;
-    private final ChangeEmailNewFactory changeEmailFactory;
-    private final OutgoingEmailNewFactory outgoingEmailFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
 
     @Inject
     CommentChangeEmailFactories(
         EmailArguments args,
         CommentChangeEmailDecoratorFactory commentChangeEmailFactory,
-        ChangeEmailNewFactory changeEmailFactory,
-        OutgoingEmailNewFactory outgoingEmailFactory) {
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
       this.args = args;
       this.commentChangeEmailFactory = commentChangeEmailFactory;
       this.changeEmailFactory = changeEmailFactory;
@@ -151,7 +147,7 @@
           project, changeId, preUpdateMetaId, postUpdateSubmitRequirementResults);
     }
 
-    public ChangeEmailNew createChangeEmail(
+    public ChangeEmail createChangeEmail(
         Project.NameKey project,
         Change.Id changeId,
         CommentChangeEmailDecorator commentChangeEmailDecorator) {
@@ -159,21 +155,21 @@
           args.newChangeData(project, changeId), commentChangeEmailDecorator);
     }
 
-    public OutgoingEmailNew createEmail(ChangeEmailNew changeEmail) {
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
       return outgoingEmailFactory.create("comment", changeEmail);
     }
   }
 
   public static class DeleteReviewerChangeEmailFactories {
     private final EmailArguments args;
-    private final ChangeEmailNewFactory changeEmailFactory;
-    private final OutgoingEmailNewFactory outgoingEmailFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
 
     @Inject
     DeleteReviewerChangeEmailFactories(
         EmailArguments args,
-        ChangeEmailNewFactory changeEmailFactory,
-        OutgoingEmailNewFactory outgoingEmailFactory) {
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
       this.args = args;
       this.changeEmailFactory = changeEmailFactory;
       this.outgoingEmailFactory = outgoingEmailFactory;
@@ -183,7 +179,7 @@
       return new DeleteReviewerChangeEmailDecorator();
     }
 
-    public ChangeEmailNew createChangeEmail(
+    public ChangeEmail createChangeEmail(
         Project.NameKey project,
         Change.Id changeId,
         DeleteReviewerChangeEmailDecorator deleteReviewerChangeEmailDecorator) {
@@ -191,22 +187,22 @@
           args.newChangeData(project, changeId), deleteReviewerChangeEmailDecorator);
     }
 
-    public OutgoingEmailNew createEmail(ChangeEmailNew changeEmail) {
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
       return outgoingEmailFactory.create("deleteReviewer", changeEmail);
     }
   }
 
   public static class DeleteVoteChangeEmailFactories {
     private final EmailArguments args;
-    private final ChangeEmailNewFactory changeEmailFactory;
-    private final OutgoingEmailNewFactory outgoingEmailFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
     private final DeleteVoteChangeEmailDecorator deleteVoteChangeEmailDecorator;
 
     @Inject
     DeleteVoteChangeEmailFactories(
         EmailArguments args,
-        ChangeEmailNewFactory changeEmailFactory,
-        OutgoingEmailNewFactory outgoingEmailFactory,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory,
         DeleteVoteChangeEmailDecorator deleteVoteChangeEmailDecorator) {
       this.args = args;
       this.changeEmailFactory = changeEmailFactory;
@@ -214,12 +210,12 @@
       this.deleteVoteChangeEmailDecorator = deleteVoteChangeEmailDecorator;
     }
 
-    public ChangeEmailNew createChangeEmail(Project.NameKey project, Change.Id changeId) {
+    public ChangeEmail createChangeEmail(Project.NameKey project, Change.Id changeId) {
       return changeEmailFactory.create(
           args.newChangeData(project, changeId), deleteVoteChangeEmailDecorator);
     }
 
-    public OutgoingEmailNew createEmail(ChangeEmailNew changeEmail) {
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
       return outgoingEmailFactory.create("deleteVote", changeEmail);
     }
   }
@@ -227,29 +223,29 @@
   public static class MergedChangeEmailFactories {
     private final EmailArguments args;
     private final MergedChangeEmailDecoratorFactory mergedChangeEmailDecoratorFactory;
-    private final ChangeEmailNewFactory changeEmailFactory;
-    private final OutgoingEmailNewFactory outgoingEmailFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
 
     @Inject
     MergedChangeEmailFactories(
         EmailArguments args,
         MergedChangeEmailDecoratorFactory mergedChangeEmailDecoratorFactory,
-        ChangeEmailNewFactory changeEmailFactory,
-        OutgoingEmailNewFactory outgoingEmailFactory) {
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
       this.args = args;
       this.mergedChangeEmailDecoratorFactory = mergedChangeEmailDecoratorFactory;
       this.changeEmailFactory = changeEmailFactory;
       this.outgoingEmailFactory = outgoingEmailFactory;
     }
 
-    public ChangeEmailNew createChangeEmail(
+    public ChangeEmail createChangeEmail(
         Project.NameKey project, Change.Id changeId, Optional<String> stickyApprovalDiff) {
       return changeEmailFactory.create(
           args.newChangeData(project, changeId),
           mergedChangeEmailDecoratorFactory.create(stickyApprovalDiff));
     }
 
-    public OutgoingEmailNew createEmail(ChangeEmailNew changeEmail) {
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
       return outgoingEmailFactory.create("merged", changeEmail);
     }
   }
@@ -258,15 +254,15 @@
     private final EmailArguments args;
     private final ReplacePatchSetChangeEmailDecoratorFactory
         replacePatchSetChangeEmailDecoratorFactory;
-    private final ChangeEmailNewFactory changeEmailFactory;
-    private final OutgoingEmailNewFactory outgoingEmailFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
 
     @Inject
     ReplacePatchSetChangeEmailFactories(
         EmailArguments args,
         ReplacePatchSetChangeEmailDecoratorFactory replacePatchSetChangeEmailDecoratorFactory,
-        ChangeEmailNewFactory changeEmailFactory,
-        OutgoingEmailNewFactory outgoingEmailFactory) {
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
       this.args = args;
       this.replacePatchSetChangeEmailDecoratorFactory = replacePatchSetChangeEmailDecoratorFactory;
       this.changeEmailFactory = changeEmailFactory;
@@ -283,7 +279,7 @@
           project, changeId, changeKind, preUpdateMetaId, postUpdateSubmitRequirementResults);
     }
 
-    public ChangeEmailNew createChangeEmail(
+    public ChangeEmail createChangeEmail(
         Project.NameKey project,
         Change.Id changeId,
         ReplacePatchSetChangeEmailDecorator replacePatchSetChangeEmailDecoratorFactory) {
@@ -291,22 +287,22 @@
           args.newChangeData(project, changeId), replacePatchSetChangeEmailDecoratorFactory);
     }
 
-    public OutgoingEmailNew createEmail(ChangeEmailNew changeEmail) {
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
       return outgoingEmailFactory.create("newpatchset", changeEmail);
     }
   }
 
   public static class RestoredChangeEmailFactories {
     private final EmailArguments args;
-    private final ChangeEmailNewFactory changeEmailFactory;
-    private final OutgoingEmailNewFactory outgoingEmailFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
     private final RestoredChangeEmailDecorator restoredChangeEmailDecorator;
 
     @Inject
     RestoredChangeEmailFactories(
         EmailArguments args,
-        ChangeEmailNewFactory changeEmailFactory,
-        OutgoingEmailNewFactory outgoingEmailFactory,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory,
         RestoredChangeEmailDecorator restoredChangeEmailDecorator) {
       this.args = args;
       this.changeEmailFactory = changeEmailFactory;
@@ -314,27 +310,27 @@
       this.restoredChangeEmailDecorator = restoredChangeEmailDecorator;
     }
 
-    public ChangeEmailNew createChangeEmail(Project.NameKey project, Change.Id changeId) {
+    public ChangeEmail createChangeEmail(Project.NameKey project, Change.Id changeId) {
       return changeEmailFactory.create(
           args.newChangeData(project, changeId), restoredChangeEmailDecorator);
     }
 
-    public OutgoingEmailNew createEmail(ChangeEmailNew changeEmail) {
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
       return outgoingEmailFactory.create("restore", changeEmail);
     }
   }
 
   public static class RevertedChangeEmailFactories {
     private final EmailArguments args;
-    private final ChangeEmailNewFactory changeEmailFactory;
-    private final OutgoingEmailNewFactory outgoingEmailFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
     private final RevertedChangeEmailDecorator revertedChangeEmailDecorator;
 
     @Inject
     RevertedChangeEmailFactories(
         EmailArguments args,
-        ChangeEmailNewFactory changeEmailFactory,
-        OutgoingEmailNewFactory outgoingEmailFactory,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory,
         RevertedChangeEmailDecorator revertedChangeEmailDecorator) {
       this.args = args;
       this.changeEmailFactory = changeEmailFactory;
@@ -342,13 +338,144 @@
       this.revertedChangeEmailDecorator = revertedChangeEmailDecorator;
     }
 
-    public ChangeEmailNew createChangeEmail(Project.NameKey project, Change.Id changeId) {
+    public ChangeEmail createChangeEmail(Project.NameKey project, Change.Id changeId) {
       return changeEmailFactory.create(
           args.newChangeData(project, changeId), revertedChangeEmailDecorator);
     }
 
-    public OutgoingEmailNew createEmail(ChangeEmailNew changeEmail) {
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
       return outgoingEmailFactory.create("revert", changeEmail);
     }
   }
+
+  public static class StartReviewChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    StartReviewChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public StartReviewChangeEmailDecorator createStartReviewChangeEmail() {
+      return new StartReviewChangeEmailDecorator();
+    }
+
+    public ChangeEmail createChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        StartReviewChangeEmailDecorator startReviewChangeEmailDecorator) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), startReviewChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("newchange", changeEmail);
+    }
+  }
+
+  public static class AddKeyEmailFactories {
+    private final AddKeyEmailDecoratorFactory addKeyEmailDecoratorFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    AddKeyEmailFactories(
+        AddKeyEmailDecoratorFactory addKeyEmailDecoratorFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.addKeyEmailDecoratorFactory = addKeyEmailDecoratorFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, AccountSshKey sshKey) {
+      return outgoingEmailFactory.create(
+          "addkey", addKeyEmailDecoratorFactory.create(user, sshKey));
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, List<String> gpgKeys) {
+      return outgoingEmailFactory.create(
+          "addkey", addKeyEmailDecoratorFactory.create(user, gpgKeys));
+    }
+  }
+
+  public static class DeleteKeyEmailFactories {
+    private final DeleteKeyEmailDecoratorFactory deleteKeyEmailDecoratorFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    DeleteKeyEmailFactories(
+        DeleteKeyEmailDecoratorFactory deleteKeyEmailDecoratorFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.deleteKeyEmailDecoratorFactory = deleteKeyEmailDecoratorFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, AccountSshKey sshKey) {
+      return outgoingEmailFactory.create(
+          "deletekey", deleteKeyEmailDecoratorFactory.create(user, sshKey));
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, List<String> gpgKeyFingerprints) {
+      return outgoingEmailFactory.create(
+          "deletekey", deleteKeyEmailDecoratorFactory.create(user, gpgKeyFingerprints));
+    }
+  }
+
+  public static class HttpPasswordUpdateEmailFactory {
+    private final HttpPasswordUpdateEmailDecoratorFactory httpPasswordUpdateEmailDecoratorFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    HttpPasswordUpdateEmailFactory(
+        HttpPasswordUpdateEmailDecoratorFactory httpPasswordUpdateEmailDecoratorFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.httpPasswordUpdateEmailDecoratorFactory = httpPasswordUpdateEmailDecoratorFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, String operation) {
+      return outgoingEmailFactory.create(
+          "HttpPasswordUpdate", httpPasswordUpdateEmailDecoratorFactory.create(user, operation));
+    }
+  }
+
+  public static class InboundEmailRejectionEmailFactory {
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    InboundEmailRejectionEmailFactory(OutgoingEmailFactory outgoingEmailFactory) {
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public OutgoingEmail createEmail(Address to, String threadId, InboundEmailError reason) {
+      return outgoingEmailFactory.create(
+          "error", new InboundEmailRejectionEmailDecorator(to, threadId, reason));
+    }
+  }
+
+  public static class RegisterNewEmailFactories {
+    private final RegisterNewEmailDecoratorFactory registerEmailDecoratorFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    RegisterNewEmailFactories(
+        RegisterNewEmailDecoratorFactory registerEmailDecoratorFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.registerEmailDecoratorFactory = registerEmailDecoratorFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public RegisterNewEmailDecorator createRegisterNewEmail(String address) {
+      return registerEmailDecoratorFactory.create(address);
+    }
+
+    public OutgoingEmail createEmail(RegisterNewEmailDecorator registerEmail) {
+      return outgoingEmailFactory.create("registernewemail", registerEmail);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/EmailTokenVerifier.java b/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
index ead4c06..ea55a24 100644
--- a/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
@@ -16,9 +16,8 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 
-/** Verifies the token sent by {@link RegisterNewEmailSender}. */
+/** Verifies the token used by new email address verification process. */
 public interface EmailTokenVerifier {
   /**
    * Construct a token to verify an email address for a user.
diff --git a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
index 36e801b..82ffda2 100644
--- a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
@@ -21,14 +21,13 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-/** Verifies the token sent by {@link RegisterNewEmailSender}. */
+/** Verifies the token used by new email address verification process. */
 @Singleton
 public class SignedTokenEmailTokenVerifier implements EmailTokenVerifier {
   private final SignedToken emailRegistrationToken;
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 93da997..7fe0515 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -56,10 +56,11 @@
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.mail.EmailModule.InboundEmailRejectionEmailFactory;
 import com.google.gerrit.server.mail.MailFilter;
-import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
-import com.google.gerrit.server.mail.send.InboundEmailRejectionSender.InboundEmailError;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator.InboundEmailError;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -108,7 +109,7 @@
                   CommentForValidation.CommentType.INLINE_COMMENT);
 
   private final Emails emails;
-  private final InboundEmailRejectionSender.Factory emailRejectionSender;
+  private final InboundEmailRejectionEmailFactory inboundEmailRejectionEmailFactory;
   private final RetryHelper retryHelper;
   private final ChangeMessagesUtil changeMessagesUtil;
   private final CommentsUtil commentsUtil;
@@ -127,7 +128,7 @@
   @Inject
   public MailProcessor(
       Emails emails,
-      InboundEmailRejectionSender.Factory emailRejectionSender,
+      InboundEmailRejectionEmailFactory inboundEmailRejectionEmailFactory,
       RetryHelper retryHelper,
       ChangeMessagesUtil changeMessagesUtil,
       CommentsUtil commentsUtil,
@@ -143,7 +144,7 @@
       PluginSetContext<CommentValidator> commentValidators,
       MessageIdGenerator messageIdGenerator) {
     this.emails = emails;
-    this.emailRejectionSender = emailRejectionSender;
+    this.inboundEmailRejectionEmailFactory = inboundEmailRejectionEmailFactory;
     this.retryHelper = retryHelper;
     this.changeMessagesUtil = changeMessagesUtil;
     this.commentsUtil = commentsUtil;
@@ -228,10 +229,10 @@
 
   private void sendRejectionEmail(MailMessage message, InboundEmailError reason) {
     try {
-      InboundEmailRejectionSender emailSender =
-          emailRejectionSender.create(message.from(), message.id(), reason);
-      emailSender.setMessageId(messageIdGenerator.fromMailMessage(message));
-      emailSender.send();
+      OutgoingEmail email =
+          inboundEmailRejectionEmailFactory.createEmail(message.from(), message.id(), reason);
+      email.setMessageId(messageIdGenerator.fromMailMessage(message));
+      email.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot send email to warn for an error");
     }
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java
index 2d25eba..3ec2b35 100644
--- a/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java
@@ -18,12 +18,12 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 
 /** Send notice about a change being abandoned by its owner. */
-public class AbandonedChangeEmailDecorator implements ChangeEmailNew.ChangeEmailDecorator {
-  private ChangeEmailNew changeEmail;
-  private OutgoingEmailNew email;
+public class AbandonedChangeEmailDecorator implements ChangeEmail.ChangeEmailDecorator {
+  private ChangeEmail changeEmail;
+  private OutgoingEmail email;
 
   @Override
-  public void init(OutgoingEmailNew email, ChangeEmailNew changeEmail) {
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
     this.email = email;
     this.changeEmail = changeEmail;
     changeEmail.markAsReply();
diff --git a/java/com/google/gerrit/server/mail/send/AddKeyEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AddKeyEmailDecorator.java
new file mode 100644
index 0000000..61e73e3
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AddKeyEmailDecorator.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2015 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.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import java.util.List;
+
+/** Informs a user by email about the addition of an SSH or GPG key to their account. */
+@AutoFactory
+public class AddKeyEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+
+  private final IdentifiedUser user;
+  private final AccountSshKey sshKey;
+  private final List<String> gpgKeys;
+  private final MessageIdGenerator messageIdGenerator;
+
+  public AddKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, AccountSshKey sshKey) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.sshKey = sshKey;
+    this.gpgKeys = null;
+  }
+
+  public AddKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, List<String> gpgKeys) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.sshKey = null;
+    this.gpgKeys = gpgKeys;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader(
+        "Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
+    email.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+    email.addByAccountId(RecipientType.TO, user.getAccountId());
+  }
+
+  @Override
+  public boolean shouldSendMessage() {
+    if (sshKey == null && (gpgKeys == null || gpgKeys.isEmpty())) {
+      // Don't email if no keys were added.
+      return false;
+    }
+
+    return true;
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("email", getEmail());
+    email.addSoyEmailDataParam("gpgKeys", getGpgKeys());
+    email.addSoyEmailDataParam("keyType", getKeyType());
+    email.addSoyEmailDataParam("sshKey", getSshKey());
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("sshKeysSettingsUrl", email.getSettingsUrl("ssh-keys"));
+    email.addSoyEmailDataParam("gpgKeysSettingsUrl", email.getSettingsUrl("gpg-keys"));
+
+    email.appendText(email.textTemplate("AddKey"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("AddKeyHtml"));
+    }
+  }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+
+  private String getKeyType() {
+    if (sshKey != null) {
+      return "SSH";
+    } else if (gpgKeys != null) {
+      return "GPG";
+    }
+    return "Unknown";
+  }
+
+  @Nullable
+  private String getSshKey() {
+    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
+  }
+
+  @Nullable
+  private String getGpgKeys() {
+    if (gpgKeys != null) {
+      return Joiner.on("\n").join(gpgKeys);
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
deleted file mode 100644
index c7b1f2a..0000000
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (C) 2015 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.send;
-
-import com.google.common.base.Joiner;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountSshKey;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.util.List;
-
-/** Sender that informs a user by email about the addition of an SSH or GPG key to their account. */
-public class AddKeySender extends OutgoingEmail {
-  public interface Factory {
-    AddKeySender create(IdentifiedUser user, AccountSshKey sshKey);
-
-    AddKeySender create(IdentifiedUser user, List<String> gpgKey);
-  }
-
-  private final IdentifiedUser user;
-  private final AccountSshKey sshKey;
-  private final List<String> gpgKeys;
-  private final MessageIdGenerator messageIdGenerator;
-
-  @AssistedInject
-  public AddKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted AccountSshKey sshKey) {
-    super(args, "addkey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.sshKey = sshKey;
-    this.gpgKeys = null;
-  }
-
-  @AssistedInject
-  public AddKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted List<String> gpgKeys) {
-    super(args, "addkey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.sshKey = null;
-    this.gpgKeys = gpgKeys;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
-    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-    addByAccountId(RecipientType.TO, user.getAccountId());
-  }
-
-  @Override
-  protected boolean shouldSendMessage() {
-    if (sshKey == null && (gpgKeys == null || gpgKeys.isEmpty())) {
-      // Don't email if no keys were added.
-      return false;
-    }
-
-    return true;
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("AddKey"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("AddKeyHtml"));
-    }
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyEmailDataParam("email", getEmail());
-    addSoyEmailDataParam("gpgKeys", getGpgKeys());
-    addSoyEmailDataParam("keyType", getKeyType());
-    addSoyEmailDataParam("sshKey", getSshKey());
-    addSoyEmailDataParam("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-  }
-
-  private String getEmail() {
-    return user.getAccount().preferredEmail();
-  }
-
-  private String getKeyType() {
-    if (sshKey != null) {
-      return "SSH";
-    } else if (gpgKeys != null) {
-      return "GPG";
-    }
-    return "Unknown";
-  }
-
-  @Nullable
-  private String getSshKey() {
-    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
-  }
-
-  @Nullable
-  private String getGpgKeys() {
-    if (gpgKeys != null) {
-      return Joiner.on("\n").join(gpgKeys);
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecorator.java
index 38ec52f..9f1a5a8 100644
--- a/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecorator.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.mail.send.ChangeEmailNew.ChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 
 /** Base class for Attention Set email senders */
 public final class AttentionSetChangeEmailDecorator implements ChangeEmailDecorator {
@@ -25,8 +25,8 @@
     USER_REMOVED
   }
 
-  private OutgoingEmailNew email;
-  private ChangeEmailNew changeEmail;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
 
   private Account.Id attentionSetUser;
   private String reason;
@@ -45,7 +45,7 @@
   }
 
   @Override
-  public void init(OutgoingEmailNew email, ChangeEmailNew changeEmail) {
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
     this.email = email;
     this.changeEmail = changeEmail;
     changeEmail.markAsReply();
diff --git a/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java b/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
index d28311f..e26f83f 100644
--- a/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
+++ b/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
@@ -24,8 +24,6 @@
 
 /** Contains utils for email notification related to the events on project+branch. */
 class BranchEmailUtils {
-
-  // TODO: Remove after all usages are migrated to OutgoingEmailNew.
   /** Set a reasonable list id so that filters can be used to sort messages. */
   static void setListIdHeader(OutgoingEmail email, BranchNameKey branch) {
     email.setHeader(
@@ -36,17 +34,6 @@
     }
   }
 
-  /** Set a reasonable list id so that filters can be used to sort messages. */
-  static void setListIdHeader(OutgoingEmailNew email, BranchNameKey branch) {
-    email.setHeader(
-        "List-Id",
-        "<gerrit-" + branch.project().get().replace('/', '-') + "." + email.getGerritHost() + ">");
-    if (email.getSettingsUrl() != null) {
-      email.setHeader("List-Unsubscribe", "<" + email.getSettingsUrl() + ">");
-    }
-  }
-
-  // TODO: Remove after all usages are migrated to OutgoingEmailNew.
   /** Add branch information to soy template params. */
   static void addBranchData(OutgoingEmail email, EmailArguments args, BranchNameKey branch) {
     String projectName = branch.project().get();
@@ -70,29 +57,6 @@
     email.addFooter(MailHeader.BRANCH.withDelimiter() + branch.shortName());
   }
 
-  /** Add branch information to soy template params. */
-  static void addBranchData(OutgoingEmailNew email, EmailArguments args, BranchNameKey branch) {
-    String projectName = branch.project().get();
-    email.addSoyParam("projectName", projectName);
-    // shortProjectName is the project name with the path abbreviated.
-    email.addSoyParam("shortProjectName", getShortProjectName(projectName));
-
-    // instanceAndProjectName is the instance's name followed by the abbreviated project path
-    email.addSoyParam(
-        "instanceAndProjectName",
-        getInstanceAndProjectName(args.instanceNameProvider.get(), projectName));
-    email.addSoyParam("addInstanceNameInSubject", args.addInstanceNameInSubject);
-
-    email.addSoyEmailDataParam("sshHost", getSshHost(email.getGerritHost(), args.sshAddresses));
-
-    Map<String, String> branchData = new HashMap<>();
-    branchData.put("shortName", branch.shortName());
-    email.addSoyParam("branch", branchData);
-
-    email.addFooter(MailHeader.PROJECT.withDelimiter() + branch.project().get());
-    email.addFooter(MailHeader.BRANCH.withDelimiter() + branch.shortName());
-  }
-
   @Nullable
   private static String getSshHost(String gerritHost, List<String> sshAddresses) {
     String host = Iterables.getFirst(sshAddresses, null);
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index bb9f96a..0b909cd 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -17,6 +17,8 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -33,7 +35,6 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -57,8 +58,6 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.io.IOException;
-import java.net.URI;
-import java.net.URISyntaxException;
 import java.text.MessageFormat;
 import java.time.Instant;
 import java.util.Collection;
@@ -70,7 +69,6 @@
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.stream.Collectors;
-import org.apache.http.client.utils.URIBuilder;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.internal.JGitText;
@@ -79,31 +77,59 @@
 import org.eclipse.jgit.util.RawParseUtils;
 import org.eclipse.jgit.util.TemporaryBuffer;
 
-/** Sends an email to one or more interested parties. */
-public abstract class ChangeEmail extends OutgoingEmail {
+// TODO: Remove ChangeEmail and rename this class once all usages are migrated to ChangeEmailNew.
+/** Populates an email for change related notifications. */
+@AutoFactory
+public final class ChangeEmail implements OutgoingEmail.EmailDecorator {
+
+  /** Implementations of params interface populate details specific to the notification type. */
+  public interface ChangeEmailDecorator {
+    /**
+     * Stores the reference to the {@link OutgoingEmail} and {@link ChangeEmail} for the subsequent
+     * calls.
+     *
+     * <p>Both init and populateEmailContent can be called multiply times in case of retries. Init
+     * is therefore responsible for clearing up any changes which are not idempotent and
+     * initializing data for use in populateEmailContent.
+     *
+     * <p>Can be used to adjust any of the behaviour of the {@link
+     * ChangeEmail#populateEmailContent}.
+     */
+    void init(OutgoingEmail email, ChangeEmail changeEmail) throws EmailException;
+
+    /**
+     * Populate headers, recipients and body of the email.
+     *
+     * <p>Method operates on the email provided in the init method.
+     *
+     * <p>By default, all the contents and parameters of the email should be set in this method.
+     */
+    void populateEmailContent() throws EmailException;
+
+    /** If returns false email is not sent to any recipients. */
+    default boolean shouldSendMessage() throws EmailException {
+      return true;
+    }
+  }
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  protected static ChangeData newChangeData(
-      EmailArguments ea, Project.NameKey project, Change.Id id) {
-    return ea.changeDataFactory.create(project, id);
-  }
-
-  protected static ChangeData newChangeData(
-      EmailArguments ea, Project.NameKey project, Change.Id id, ObjectId metaId) {
-    return ea.changeDataFactory.create(ea.changeNotesFactory.createChecked(project, id, metaId));
-  }
-
+  // Available after construction
+  private final EmailArguments args;
   private final Set<Account.Id> currentAttentionSet;
   private final Change change;
   private final ChangeData changeData;
+  private final BranchNameKey branch;
+  private final ChangeEmailDecorator changeEmailDecorator;
+
+  // Available after init or after being explicitly set.
+  private OutgoingEmail email;
   private ListMultimap<Account.Id, String> stars;
   private PatchSet patchSet;
   private PatchSetInfo patchSetInfo;
   private String changeMessage;
+  private String changeMessageThreadId;
   private Instant timestamp;
-  private BranchNameKey branch;
-
   private ProjectState projectState;
   private Set<Account.Id> authors;
   private boolean emailOnlyAuthors;
@@ -112,15 +138,24 @@
   private Set<Account.Id> watcherAccounts = new HashSet<>();
   // Watcher can only be an email if it's specified in notify section of ProjectConfig.
   private Set<Address> watcherEmails = new HashSet<>();
+  private boolean isThreadReply = false;
 
-  protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
-    super(args, messageClass);
+  public ChangeEmail(
+      @Provided EmailArguments args,
+      ChangeData changeData,
+      ChangeEmailDecorator changeEmailDecorator) {
+    this.args = args;
     this.changeData = changeData;
     change = changeData.change();
     emailOnlyAuthors = false;
     emailOnlyAttentionSetIfEnabled = true;
     currentAttentionSet = getAttentionSet();
     branch = changeData.change().getDest();
+    this.changeEmailDecorator = changeEmailDecorator;
+  }
+
+  public void markAsReply() {
+    isThreadReply = true;
   }
 
   public Change getChange() {
@@ -131,6 +166,7 @@
     return changeData;
   }
 
+  @Nullable
   public Instant getTimestamp() {
     return timestamp;
   }
@@ -139,6 +175,7 @@
     patchSet = ps;
   }
 
+  @Nullable
   public PatchSet getPatchSet() {
     return patchSet;
   }
@@ -157,39 +194,24 @@
     emailOnlyAttentionSetIfEnabled = value;
   }
 
-  /** Format the message body by calling {@link #appendText(String)}. */
   @Override
-  protected void format() throws EmailException {
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("ChangeHeaderHtml"));
-    }
-    appendText(textTemplate("ChangeHeader"));
-    formatChange();
-    appendText(textTemplate("ChangeFooter"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("ChangeFooterHtml"));
-    }
-    formatFooter();
+  public boolean shouldSendMessage() throws EmailException {
+    return changeEmailDecorator.shouldSendMessage();
   }
 
-  /** Format the message body by calling {@link #appendText(String)}. */
-  protected abstract void formatChange() throws EmailException;
-
-  /**
-   * Format the message footer by calling {@link #appendText(String)}.
-   *
-   * @throws EmailException if an error occurred.
-   */
-  protected void formatFooter() throws EmailException {}
-
-  /** Setup the message headers and envelope (TO, CC, BCC). */
   @Override
-  protected void init() throws EmailException {
-    super.init();
-    if (getFrom() != null) {
+  public void init(OutgoingEmail email) throws EmailException {
+    this.email = email;
+
+    changeMessageThreadId =
+        String.format(
+            "<gerrit.%s.%s@%s>",
+            change.getCreatedOn().toEpochMilli(), change.getKey().get(), email.getGerritHost());
+
+    if (email.getFrom() != null) {
       // Is the from user in an email squelching group?
       try {
-        args.permissionBackend.absentUser(getFrom()).check(GlobalPermission.EMAIL_REVIEWERS);
+        args.permissionBackend.absentUser(email.getFrom()).check(GlobalPermission.EMAIL_REVIEWERS);
       } catch (AuthException | PermissionBackendException e) {
         emailOnlyAuthors = true;
       }
@@ -210,7 +232,7 @@
     }
 
     if (patchSet != null) {
-      setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.number() + "");
+      email.setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.number() + "");
       if (patchSetInfo == null) {
         try {
           patchSetInfo = args.patchSetInfoFactory.get(changeData.notes(), patchSet.id());
@@ -226,32 +248,34 @@
       throw new EmailException("Failed to load stars for change " + change.getChangeId(), e);
     }
 
-    BranchEmailUtils.setListIdHeader(this, branch);
+    BranchEmailUtils.setListIdHeader(email, branch);
     if (timestamp != null) {
-      setHeader(FieldName.DATE, timestamp);
+      email.setHeader(FieldName.DATE, timestamp);
     }
-    setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
-    setHeader(MailHeader.CHANGE_NUMBER.fieldName(), "" + change.getChangeId());
-    setHeader(MailHeader.PROJECT.fieldName(), "" + change.getProject());
+    email.setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
+    email.setHeader(MailHeader.CHANGE_NUMBER.fieldName(), "" + change.getChangeId());
+    email.setHeader(MailHeader.PROJECT.fieldName(), "" + change.getProject());
     setChangeUrlHeader();
     setCommitIdHeader();
+
+    changeEmailDecorator.init(email, this);
   }
 
   private void setChangeUrlHeader() {
     final String u = getChangeUrl();
     if (u != null) {
-      setHeader(MailHeader.CHANGE_URL.fieldName(), "<" + u + ">");
+      email.setHeader(MailHeader.CHANGE_URL.fieldName(), "<" + u + ">");
     }
   }
 
   private void setCommitIdHeader() {
     if (patchSet != null) {
-      setHeader(MailHeader.COMMIT.fieldName(), patchSet.commitId().name());
+      email.setHeader(MailHeader.COMMIT.fieldName(), patchSet.commitId().name());
     }
   }
 
   private void setChangeSubjectHeader() {
-    setHeader(FieldName.SUBJECT, textTemplate("ChangeSubject"));
+    email.setHeader(FieldName.SUBJECT, email.textTemplate("ChangeSubject"));
   }
 
   private int getInsertionsCount() {
@@ -275,25 +299,19 @@
    */
   @Nullable
   public String getChangeUrl() {
-    Optional<String> changeUrl =
-        args.urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId());
-    if (!changeUrl.isPresent()) return null;
-    try {
-      URI uri = new URIBuilder(changeUrl.get()).addParameter("usp", "email").build();
-      return uri.toString();
-    } catch (URISyntaxException e) {
-      return null;
-    }
+    return args.urlFormatter
+        .get()
+        .getChangeViewUrl(change.getProject(), change.getId())
+        .map(EmailArguments::addUspParam)
+        .orElse(null);
   }
 
-  public String getChangeMessageThreadId() {
-    return "<gerrit."
-        + change.getCreatedOn().toEpochMilli()
-        + "."
-        + change.getKey().get()
-        + "@"
-        + getGerritHost()
-        + ">";
+  /** Sets headers for conversation grouping */
+  private void setThreadHeaders() {
+    if (isThreadReply) {
+      email.setHeader("In-Reply-To", changeMessageThreadId);
+    }
+    email.setHeader("References", changeMessageThreadId);
   }
 
   /** Get the text of the "cover letter". */
@@ -390,19 +408,19 @@
   /** TO or CC all vested parties (change owner, patch set uploader, author). */
   public void addAuthors(RecipientType rt) {
     for (Account.Id id : getAuthors()) {
-      addByAccountId(rt, id);
+      email.addByAccountId(rt, id);
     }
   }
 
   /** BCC any user who has starred this change. */
   public void bccStarredBy() {
-    if (!NotifyHandling.ALL.equals(getNotify().handling())) {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())) {
       return;
     }
 
     for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
       if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
-        super.addByAccountId(RecipientType.BCC, e.getKey());
+        email.addByAccountId(RecipientType.BCC, e.getKey());
       }
     }
   }
@@ -431,17 +449,17 @@
   private void addWatchers(RecipientType type, WatcherList watcherList) {
     watcherAccounts.addAll(watcherList.accounts);
     for (Account.Id user : watcherList.accounts) {
-      addByAccountId(type, user);
+      email.addByAccountId(type, user);
     }
 
     watcherEmails.addAll(watcherList.emails);
     for (Address addr : watcherList.emails) {
-      addByEmail(type, addr);
+      email.addByEmail(type, addr);
     }
   }
 
   private final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
-    if (!NotifyHandling.ALL.equals(getNotify().handling())) {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())) {
       return new Watchers();
     }
 
@@ -451,14 +469,14 @@
 
   /** Any user who has published comments on this change. */
   public void ccAllApprovals() {
-    if (!NotifyHandling.ALL.equals(getNotify().handling())
-        && !NotifyHandling.OWNER_REVIEWERS.equals(getNotify().handling())) {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())
+        && !NotifyHandling.OWNER_REVIEWERS.equals(email.getNotify().handling())) {
       return;
     }
 
     try {
       for (Account.Id id : changeData.reviewers().all()) {
-        addByAccountId(RecipientType.CC, id);
+        email.addByAccountId(RecipientType.CC, id);
       }
     } catch (StorageException err) {
       logger.atWarning().withCause(err).log("Cannot CC users that reviewed updated change");
@@ -467,14 +485,14 @@
 
   /** Users who were added as reviewers to this change. */
   public void ccExistingReviewers() {
-    if (!NotifyHandling.ALL.equals(getNotify().handling())
-        && !NotifyHandling.OWNER_REVIEWERS.equals(getNotify().handling())) {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())
+        && !NotifyHandling.OWNER_REVIEWERS.equals(email.getNotify().handling())) {
       return;
     }
 
     try {
       for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
-        addByAccountId(RecipientType.CC, id);
+        email.addByAccountId(RecipientType.CC, id);
       }
     } catch (StorageException err) {
       logger.atWarning().withCause(err).log("Cannot CC users that commented on updated change");
@@ -482,7 +500,7 @@
   }
 
   @Override
-  protected boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
+  public boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
     if (!projectState.statePermitsRead()) {
       return false;
     }
@@ -503,7 +521,7 @@
   }
 
   @Override
-  protected boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
+  public boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
     if (!projectState.statePermitsRead()) {
       return false;
     }
@@ -521,7 +539,6 @@
         return false;
       }
     }
-
     return args.permissionBackend.absentUser(to).change(changeData).test(ChangePermission.READ);
   }
 
@@ -532,7 +549,7 @@
     }
     Set<Account.Id> authors = new HashSet<>();
 
-    switch (getNotify().handling()) {
+    switch (email.getNotify().handling()) {
       case NONE:
         break;
       case ALL:
@@ -559,20 +576,20 @@
   }
 
   @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    BranchEmailUtils.addBranchData(this, args, branch);
+  public void populateEmailContent() throws EmailException {
+    BranchEmailUtils.addBranchData(email, args, branch);
+    setThreadHeaders();
 
-    addSoyParam("changeId", change.getKey().get());
-    addSoyParam("coverLetter", getCoverLetter());
-    addSoyParam("fromName", getNameFor(getFrom()));
-    addSoyParam("fromEmail", getNameEmailFor(getFrom()));
-    addSoyParam("diffLines", getDiffTemplateData(getUnifiedDiff()));
+    email.addSoyParam("changeId", change.getKey().get());
+    email.addSoyParam("coverLetter", getCoverLetter());
+    email.addSoyParam("fromName", email.getNameFor(email.getFrom()));
+    email.addSoyParam("fromEmail", email.getNameEmailFor(email.getFrom()));
+    email.addSoyParam("diffLines", getDiffTemplateData(getUnifiedDiff()));
 
-    addSoyEmailDataParam("unifiedDiff", getUnifiedDiff());
-    addSoyEmailDataParam("changeDetail", getChangeDetail());
-    addSoyEmailDataParam("changeUrl", getChangeUrl());
-    addSoyEmailDataParam("includeDiff", getIncludeDiff());
+    email.addSoyEmailDataParam("unifiedDiff", getUnifiedDiff());
+    email.addSoyEmailDataParam("changeDetail", getChangeDetail());
+    email.addSoyEmailDataParam("changeUrl", getChangeUrl());
+    email.addSoyEmailDataParam("includeDiff", getIncludeDiff());
 
     Map<String, String> changeData = new HashMap<>();
 
@@ -583,56 +600,66 @@
     changeData.put("shortSubject", shortenSubject(subject));
     changeData.put("shortOriginalSubject", shortenSubject(originalSubject));
 
-    changeData.put("ownerName", getNameFor(change.getOwner()));
-    changeData.put("ownerEmail", getNameEmailFor(change.getOwner()));
+    changeData.put("ownerName", email.getNameFor(change.getOwner()));
+    changeData.put("ownerEmail", email.getNameEmailFor(change.getOwner()));
     changeData.put("changeNumber", Integer.toString(change.getChangeId()));
     changeData.put(
         "sizeBucket",
         ChangeSizeBucket.getChangeSizeBucket(getInsertionsCount() + getDeletionsCount()));
-    addSoyParam("change", changeData);
+    email.addSoyParam("change", changeData);
 
     Map<String, Object> patchSetData = new HashMap<>();
     patchSetData.put("patchSetId", patchSet.number());
     patchSetData.put("refName", patchSet.refName());
-    addSoyParam("patchSet", patchSetData);
+    email.addSoyParam("patchSet", patchSetData);
 
     Map<String, Object> patchSetInfoData = new HashMap<>();
     patchSetInfoData.put("authorName", patchSetInfo.getAuthor().getName());
     patchSetInfoData.put("authorEmail", patchSetInfo.getAuthor().getEmail());
-    addSoyParam("patchSetInfo", patchSetInfoData);
+    email.addSoyParam("patchSetInfo", patchSetInfoData);
 
-    addFooter(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
-    addFooter(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
-    addFooter(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
-    addFooter(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
+    email.addFooter(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
+    email.addFooter(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
+    email.addFooter(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
+    email.addFooter(MailHeader.OWNER.withDelimiter() + email.getNameEmailFor(change.getOwner()));
     for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
-      addFooter(MailHeader.REVIEWER.withDelimiter() + reviewer);
+      email.addFooter(MailHeader.REVIEWER.withDelimiter() + reviewer);
     }
     for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
-      addFooter(MailHeader.CC.withDelimiter() + reviewer);
+      email.addFooter(MailHeader.CC.withDelimiter() + reviewer);
     }
     for (Account.Id attentionUser : currentAttentionSet) {
-      addFooter(MailHeader.ATTENTION.withDelimiter() + getNameEmailFor(attentionUser));
+      email.addFooter(MailHeader.ATTENTION.withDelimiter() + email.getNameEmailFor(attentionUser));
     }
     if (!currentAttentionSet.isEmpty()) {
       // We need names rather than account ids / emails to make it user readable.
-      addSoyParam(
+      email.addSoyParam(
           "attentionSet",
-          currentAttentionSet.stream().map(this::getNameFor).sorted().collect(toImmutableList()));
+          currentAttentionSet.stream().map(email::getNameFor).sorted().collect(toImmutableList()));
     }
 
     setChangeSubjectHeader();
-    if (getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)
-        || getNotify().handling().equals(NotifyHandling.ALL)) {
+    if (email.getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)
+        || email.getNotify().handling().equals(NotifyHandling.ALL)) {
       try {
         this.changeData.reviewersByEmail().byState(ReviewerStateInternal.CC).stream()
-            .forEach(address -> addByEmail(RecipientType.CC, address));
+            .forEach(address -> email.addByEmail(RecipientType.CC, address));
         this.changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER).stream()
-            .forEach(address -> addByEmail(RecipientType.CC, address));
+            .forEach(address -> email.addByEmail(RecipientType.CC, address));
       } catch (StorageException e) {
         throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
       }
     }
+
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("ChangeHeaderHtml"));
+    }
+    email.appendText(email.textTemplate("ChangeHeader"));
+    changeEmailDecorator.populateEmailContent();
+    email.appendText(email.textTemplate("ChangeFooter"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("ChangeFooterHtml"));
+    }
   }
 
   /**
@@ -650,7 +677,7 @@
     Set<String> reviewers = new TreeSet<>();
     try {
       for (Account.Id who : changeData.reviewers().byState(state)) {
-        reviewers.add(getNameEmailFor(who));
+        reviewers.add(email.getNameEmailFor(who));
       }
     } catch (StorageException e) {
       logger.atWarning().withCause(e).log("Cannot get change reviewers");
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmailNew.java b/java/com/google/gerrit/server/mail/send/ChangeEmailNew.java
deleted file mode 100644
index 28dd3b1..0000000
--- a/java/com/google/gerrit/server/mail/send/ChangeEmailNew.java
+++ /dev/null
@@ -1,789 +0,0 @@
-// 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.send;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
-
-import com.google.auto.factory.AutoFactory;
-import com.google.auto.factory.Provided;
-import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.entities.AttentionSetUpdate;
-import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeSizeBucket;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Patch;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetInfo;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.mail.MailHeader;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
-import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffOptions;
-import com.google.gerrit.server.patch.FilePathAdapter;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
-import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.io.IOException;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.text.MessageFormat;
-import java.time.Instant;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.TreeMap;
-import java.util.TreeSet;
-import java.util.stream.Collectors;
-import org.apache.http.client.utils.URIBuilder;
-import org.apache.james.mime4j.dom.field.FieldName;
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.internal.JGitText;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.util.RawParseUtils;
-import org.eclipse.jgit.util.TemporaryBuffer;
-
-// TODO: Remove ChangeEmail and rename this class once all usages are migrated to ChangeEmailNew.
-/** Populates an email for change related notifications. */
-@AutoFactory
-public final class ChangeEmailNew implements OutgoingEmailNew.EmailDecorator {
-
-  /** Implementations of params interface populate details specific to the notification type. */
-  public interface ChangeEmailDecorator {
-    /**
-     * Stores the reference to the {@link OutgoingEmailNew} and {@link ChangeEmailNew} for the
-     * subsequent calls.
-     *
-     * <p>Both init and populateEmailContent can be called multiply times in case of retries. Init
-     * is therefore responsible for clearing up any changes which are not idempotent and
-     * initializing data for use in populateEmailContent.
-     *
-     * <p>Can be used to adjust any of the behaviour of the {@link
-     * ChangeEmailNew#populateEmailContent}.
-     */
-    void init(OutgoingEmailNew email, ChangeEmailNew changeEmail) throws EmailException;
-
-    /**
-     * Populate headers, recipients and body of the email.
-     *
-     * <p>Method operates on the email provided in the init method.
-     *
-     * <p>By default, all the contents and parameters of the email should be set in this method.
-     */
-    void populateEmailContent() throws EmailException;
-
-    /** If returns false email is not sent to any recipients. */
-    default boolean shouldSendMessage() throws EmailException {
-      return true;
-    }
-  }
-
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  // Available after construction
-  private final EmailArguments args;
-  private final Set<Account.Id> currentAttentionSet;
-  private final Change change;
-  private final ChangeData changeData;
-  private final BranchNameKey branch;
-  private final ChangeEmailDecorator changeEmailDecorator;
-
-  // Available after init or after being explicitly set.
-  private OutgoingEmailNew email;
-  private ListMultimap<Account.Id, String> stars;
-  private PatchSet patchSet;
-  private PatchSetInfo patchSetInfo;
-  private String changeMessage;
-  private String changeMessageThreadId;
-  private Instant timestamp;
-  private ProjectState projectState;
-  private Set<Account.Id> authors;
-  private boolean emailOnlyAuthors;
-  private boolean emailOnlyAttentionSetIfEnabled;
-  // Watchers ignore attention set rules.
-  private Set<Account.Id> watcherAccounts = new HashSet<>();
-  // Watcher can only be an email if it's specified in notify section of ProjectConfig.
-  private Set<Address> watcherEmails = new HashSet<>();
-  private boolean isThreadReply = false;
-
-  public ChangeEmailNew(
-      @Provided EmailArguments args,
-      ChangeData changeData,
-      ChangeEmailDecorator changeEmailDecorator) {
-    this.args = args;
-    this.changeData = changeData;
-    change = changeData.change();
-    emailOnlyAuthors = false;
-    emailOnlyAttentionSetIfEnabled = true;
-    currentAttentionSet = getAttentionSet();
-    branch = changeData.change().getDest();
-    this.changeEmailDecorator = changeEmailDecorator;
-  }
-
-  public void markAsReply() {
-    isThreadReply = true;
-  }
-
-  public Change getChange() {
-    return change;
-  }
-
-  public ChangeData getChangeData() {
-    return changeData;
-  }
-
-  @Nullable
-  public Instant getTimestamp() {
-    return timestamp;
-  }
-
-  public void setPatchSet(PatchSet ps) {
-    patchSet = ps;
-  }
-
-  @Nullable
-  public PatchSet getPatchSet() {
-    return patchSet;
-  }
-
-  public void setPatchSet(PatchSet ps, PatchSetInfo psi) {
-    patchSet = ps;
-    patchSetInfo = psi;
-  }
-
-  public void setChangeMessage(String cm, Instant t) {
-    changeMessage = cm;
-    timestamp = t;
-  }
-
-  public void setEmailOnlyAttentionSetIfEnabled(boolean value) {
-    emailOnlyAttentionSetIfEnabled = value;
-  }
-
-  @Override
-  public boolean shouldSendMessage() throws EmailException {
-    return changeEmailDecorator.shouldSendMessage();
-  }
-
-  @Override
-  public void init(OutgoingEmailNew email) throws EmailException {
-    this.email = email;
-
-    changeMessageThreadId =
-        String.format(
-            "<gerrit.%s.%s@%s>",
-            change.getCreatedOn().toEpochMilli(), change.getKey().get(), email.getGerritHost());
-
-    if (email.getFrom() != null) {
-      // Is the from user in an email squelching group?
-      try {
-        args.permissionBackend.absentUser(email.getFrom()).check(GlobalPermission.EMAIL_REVIEWERS);
-      } catch (AuthException | PermissionBackendException e) {
-        emailOnlyAuthors = true;
-      }
-    }
-
-    if (args.projectCache != null) {
-      projectState = args.projectCache.get(change.getProject()).orElse(null);
-    } else {
-      projectState = null;
-    }
-
-    if (patchSet == null) {
-      try {
-        patchSet = changeData.currentPatchSet();
-      } catch (StorageException err) {
-        patchSet = null;
-      }
-    }
-
-    if (patchSet != null) {
-      email.setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.number() + "");
-      if (patchSetInfo == null) {
-        try {
-          patchSetInfo = args.patchSetInfoFactory.get(changeData.notes(), patchSet.id());
-        } catch (PatchSetInfoNotAvailableException | StorageException err) {
-          patchSetInfo = null;
-        }
-      }
-    }
-
-    try {
-      stars = changeData.stars();
-    } catch (StorageException e) {
-      throw new EmailException("Failed to load stars for change " + change.getChangeId(), e);
-    }
-
-    BranchEmailUtils.setListIdHeader(email, branch);
-    if (timestamp != null) {
-      email.setHeader(FieldName.DATE, timestamp);
-    }
-    email.setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
-    email.setHeader(MailHeader.CHANGE_NUMBER.fieldName(), "" + change.getChangeId());
-    email.setHeader(MailHeader.PROJECT.fieldName(), "" + change.getProject());
-    setChangeUrlHeader();
-    setCommitIdHeader();
-
-    changeEmailDecorator.init(email, this);
-  }
-
-  private void setChangeUrlHeader() {
-    final String u = getChangeUrl();
-    if (u != null) {
-      email.setHeader(MailHeader.CHANGE_URL.fieldName(), "<" + u + ">");
-    }
-  }
-
-  private void setCommitIdHeader() {
-    if (patchSet != null) {
-      email.setHeader(MailHeader.COMMIT.fieldName(), patchSet.commitId().name());
-    }
-  }
-
-  private void setChangeSubjectHeader() {
-    email.setHeader(FieldName.SUBJECT, email.textTemplate("ChangeSubject"));
-  }
-
-  private int getInsertionsCount() {
-    return listModifiedFiles().entrySet().stream()
-        .filter(e -> !Patch.COMMIT_MSG.equals(e.getKey()))
-        .map(Map.Entry::getValue)
-        .map(FileDiffOutput::insertions)
-        .reduce(0, Integer::sum);
-  }
-
-  private int getDeletionsCount() {
-    return listModifiedFiles().values().stream()
-        .map(FileDiffOutput::deletions)
-        .reduce(0, Integer::sum);
-  }
-
-  /**
-   * Get a link to the change; null if the server doesn't know its own address or if the address is
-   * malformed. The link will contain a usp parameter set to "email" to inform the frontend on
-   * clickthroughs where the link came from.
-   */
-  @Nullable
-  public String getChangeUrl() {
-    Optional<String> changeUrl =
-        args.urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId());
-    if (!changeUrl.isPresent()) return null;
-    try {
-      URI uri = new URIBuilder(changeUrl.get()).addParameter("usp", "email").build();
-      return uri.toString();
-    } catch (URISyntaxException e) {
-      return null;
-    }
-  }
-
-  /** Sets headers for conversation grouping */
-  private void setThreadHeaders() {
-    if (isThreadReply) {
-      email.setHeader("In-Reply-To", changeMessageThreadId);
-    }
-    email.setHeader("References", changeMessageThreadId);
-  }
-
-  /** Get the text of the "cover letter". */
-  public String getCoverLetter() {
-    if (changeMessage != null) {
-      return changeMessage.trim();
-    }
-    return "";
-  }
-
-  /** Create the change message and the affected file list. */
-  public String getChangeDetail() {
-    try {
-      StringBuilder detail = new StringBuilder();
-
-      if (patchSetInfo != null) {
-        detail.append(patchSetInfo.getMessage().trim()).append("\n");
-      } else {
-        detail.append(change.getSubject().trim()).append("\n");
-      }
-
-      if (patchSet != null) {
-        detail.append("---\n");
-        // Sort files by name.
-        TreeMap<String, FileDiffOutput> modifiedFiles = new TreeMap<>(listModifiedFiles());
-        for (FileDiffOutput fileDiff : modifiedFiles.values()) {
-          if (fileDiff.newPath().isPresent() && Patch.isMagic(fileDiff.newPath().get())) {
-            continue;
-          }
-          detail
-              .append(fileDiff.changeType().getCode())
-              .append(" ")
-              .append(
-                  FilePathAdapter.getNewPath(
-                      fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType()))
-              .append("\n");
-        }
-        detail.append(
-            MessageFormat.format(
-                "" //
-                    + "{0,choice,0#0 files|1#1 file|1<{0} files} changed, " //
-                    + "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
-                    + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
-                    + "\n",
-                modifiedFiles.size() - 1, // -1 to account for the commit message
-                getInsertionsCount(),
-                getDeletionsCount()));
-        detail.append("\n");
-      }
-      return detail.toString();
-    } catch (Exception err) {
-      logger.atWarning().withCause(err).log("Cannot format change detail");
-      return "";
-    }
-  }
-
-  /** Get the patch list corresponding to patch set patchSetId of this change. */
-  public Map<String, FileDiffOutput> listModifiedFiles(int patchSetId) {
-    try {
-      PatchSet ps;
-      if (patchSetId == patchSet.number()) {
-        ps = patchSet;
-      } else {
-        ps = args.patchSetUtil.get(changeData.notes(), PatchSet.id(change.getId(), patchSetId));
-      }
-      return args.diffOperations.listModifiedFilesAgainstParent(
-          change.getProject(), ps.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
-    } catch (StorageException | DiffNotAvailableException e) {
-      logger.atSevere().withCause(e).log("Failed to get modified files");
-      return new HashMap<>();
-    }
-  }
-
-  /** Get the patch list corresponding to this patch set. */
-  public Map<String, FileDiffOutput> listModifiedFiles() {
-    if (patchSet != null) {
-      try {
-        return args.diffOperations.listModifiedFilesAgainstParent(
-            change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
-      } catch (DiffNotAvailableException e) {
-        logger.atSevere().withCause(e).log("Failed to get modified files");
-      }
-    } else {
-      logger.atSevere().log("no patchSet specified");
-    }
-    return new HashMap<>();
-  }
-
-  /** Get the project entity the change is in; null if its been deleted. */
-  public ProjectState getProjectState() {
-    return projectState;
-  }
-
-  /** TO or CC all vested parties (change owner, patch set uploader, author). */
-  public void addAuthors(RecipientType rt) {
-    for (Account.Id id : getAuthors()) {
-      email.addByAccountId(rt, id);
-    }
-  }
-
-  /** BCC any user who has starred this change. */
-  public void bccStarredBy() {
-    if (!NotifyHandling.ALL.equals(email.getNotify().handling())) {
-      return;
-    }
-
-    for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
-      if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
-        email.addByAccountId(RecipientType.BCC, e.getKey());
-      }
-    }
-  }
-
-  /** Include users and groups that want notification of events. */
-  public void includeWatchers(NotifyType type) {
-    includeWatchers(type, true);
-  }
-
-  /** Include users and groups that want notification of events. */
-  public void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
-    try {
-      Watchers matching = getWatchers(type, includeWatchersFromNotifyConfig);
-      addWatchers(RecipientType.TO, matching.to);
-      addWatchers(RecipientType.CC, matching.cc);
-      addWatchers(RecipientType.BCC, matching.bcc);
-    } catch (StorageException err) {
-      // Just don't CC everyone. Better to send a partial message to those
-      // we already have queued up then to fail deliver entirely to people
-      // who have a lower interest in the change.
-      logger.atWarning().withCause(err).log("Cannot BCC watchers for %s", type);
-    }
-  }
-
-  /** Add users or email addresses to the TO, CC, or BCC list. */
-  private void addWatchers(RecipientType type, WatcherList watcherList) {
-    watcherAccounts.addAll(watcherList.accounts);
-    for (Account.Id user : watcherList.accounts) {
-      email.addByAccountId(type, user);
-    }
-
-    watcherEmails.addAll(watcherList.emails);
-    for (Address addr : watcherList.emails) {
-      email.addByEmail(type, addr);
-    }
-  }
-
-  private final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
-    if (!NotifyHandling.ALL.equals(email.getNotify().handling())) {
-      return new Watchers();
-    }
-
-    ProjectWatch watch = new ProjectWatch(args, branch.project(), projectState, changeData);
-    return watch.getWatchers(type, includeWatchersFromNotifyConfig);
-  }
-
-  /** Any user who has published comments on this change. */
-  public void ccAllApprovals() {
-    if (!NotifyHandling.ALL.equals(email.getNotify().handling())
-        && !NotifyHandling.OWNER_REVIEWERS.equals(email.getNotify().handling())) {
-      return;
-    }
-
-    try {
-      for (Account.Id id : changeData.reviewers().all()) {
-        email.addByAccountId(RecipientType.CC, id);
-      }
-    } catch (StorageException err) {
-      logger.atWarning().withCause(err).log("Cannot CC users that reviewed updated change");
-    }
-  }
-
-  /** Users who were added as reviewers to this change. */
-  public void ccExistingReviewers() {
-    if (!NotifyHandling.ALL.equals(email.getNotify().handling())
-        && !NotifyHandling.OWNER_REVIEWERS.equals(email.getNotify().handling())) {
-      return;
-    }
-
-    try {
-      for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
-        email.addByAccountId(RecipientType.CC, id);
-      }
-    } catch (StorageException err) {
-      logger.atWarning().withCause(err).log("Cannot CC users that commented on updated change");
-    }
-  }
-
-  @Override
-  public boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
-    if (!projectState.statePermitsRead()) {
-      return false;
-    }
-    if (emailOnlyAuthors) {
-      return false;
-    }
-
-    // If the email is a watcher email, skip permission check. An email can only be a watcher if
-    // it is specified in notify section of ProjectConfig, so we trust that the recipient is
-    // allowed.
-    if (watcherEmails.contains(addr)) {
-      return true;
-    }
-    return args.permissionBackend
-        .user(args.anonymousUser.get())
-        .change(changeData)
-        .test(ChangePermission.READ);
-  }
-
-  @Override
-  public boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
-    if (!projectState.statePermitsRead()) {
-      return false;
-    }
-    if (emailOnlyAuthors && !getAuthors().contains(to)) {
-      return false;
-    }
-    // Watchers ignore AttentionSet rules.
-    if (!watcherAccounts.contains(to)) {
-      Optional<AccountState> accountState = args.accountCache.get(to);
-      if (emailOnlyAttentionSetIfEnabled
-          && accountState.isPresent()
-          && accountState.get().generalPreferences().getEmailStrategy()
-              == EmailStrategy.ATTENTION_SET_ONLY
-          && !currentAttentionSet.contains(to)) {
-        return false;
-      }
-    }
-    return args.permissionBackend.absentUser(to).change(changeData).test(ChangePermission.READ);
-  }
-
-  /** Lazily finds all users who are authors of any part of this change. */
-  private Set<Account.Id> getAuthors() {
-    if (this.authors != null) {
-      return this.authors;
-    }
-    Set<Account.Id> authors = new HashSet<>();
-
-    switch (email.getNotify().handling()) {
-      case NONE:
-        break;
-      case ALL:
-      default:
-        if (patchSet != null) {
-          authors.add(patchSet.uploader());
-        }
-        if (patchSetInfo != null) {
-          if (patchSetInfo.getAuthor().getAccount() != null) {
-            authors.add(patchSetInfo.getAuthor().getAccount());
-          }
-          if (patchSetInfo.getCommitter().getAccount() != null) {
-            authors.add(patchSetInfo.getCommitter().getAccount());
-          }
-        }
-        // $FALL-THROUGH$
-      case OWNER_REVIEWERS:
-      case OWNER:
-        authors.add(change.getOwner());
-        break;
-    }
-
-    return this.authors = authors;
-  }
-
-  @Override
-  public void populateEmailContent() throws EmailException {
-    BranchEmailUtils.addBranchData(email, args, branch);
-    setThreadHeaders();
-
-    email.addSoyParam("changeId", change.getKey().get());
-    email.addSoyParam("coverLetter", getCoverLetter());
-    email.addSoyParam("fromName", email.getNameFor(email.getFrom()));
-    email.addSoyParam("fromEmail", email.getNameEmailFor(email.getFrom()));
-    email.addSoyParam("diffLines", getDiffTemplateData(getUnifiedDiff()));
-
-    email.addSoyEmailDataParam("unifiedDiff", getUnifiedDiff());
-    email.addSoyEmailDataParam("changeDetail", getChangeDetail());
-    email.addSoyEmailDataParam("changeUrl", getChangeUrl());
-    email.addSoyEmailDataParam("includeDiff", getIncludeDiff());
-
-    Map<String, String> changeData = new HashMap<>();
-
-    String subject = change.getSubject();
-    String originalSubject = change.getOriginalSubject();
-    changeData.put("subject", subject);
-    changeData.put("originalSubject", originalSubject);
-    changeData.put("shortSubject", shortenSubject(subject));
-    changeData.put("shortOriginalSubject", shortenSubject(originalSubject));
-
-    changeData.put("ownerName", email.getNameFor(change.getOwner()));
-    changeData.put("ownerEmail", email.getNameEmailFor(change.getOwner()));
-    changeData.put("changeNumber", Integer.toString(change.getChangeId()));
-    changeData.put(
-        "sizeBucket",
-        ChangeSizeBucket.getChangeSizeBucket(getInsertionsCount() + getDeletionsCount()));
-    email.addSoyParam("change", changeData);
-
-    Map<String, Object> patchSetData = new HashMap<>();
-    patchSetData.put("patchSetId", patchSet.number());
-    patchSetData.put("refName", patchSet.refName());
-    email.addSoyParam("patchSet", patchSetData);
-
-    Map<String, Object> patchSetInfoData = new HashMap<>();
-    patchSetInfoData.put("authorName", patchSetInfo.getAuthor().getName());
-    patchSetInfoData.put("authorEmail", patchSetInfo.getAuthor().getEmail());
-    email.addSoyParam("patchSetInfo", patchSetInfoData);
-
-    email.addFooter(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
-    email.addFooter(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
-    email.addFooter(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
-    email.addFooter(MailHeader.OWNER.withDelimiter() + email.getNameEmailFor(change.getOwner()));
-    for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
-      email.addFooter(MailHeader.REVIEWER.withDelimiter() + reviewer);
-    }
-    for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
-      email.addFooter(MailHeader.CC.withDelimiter() + reviewer);
-    }
-    for (Account.Id attentionUser : currentAttentionSet) {
-      email.addFooter(MailHeader.ATTENTION.withDelimiter() + email.getNameEmailFor(attentionUser));
-    }
-    if (!currentAttentionSet.isEmpty()) {
-      // We need names rather than account ids / emails to make it user readable.
-      email.addSoyParam(
-          "attentionSet",
-          currentAttentionSet.stream().map(email::getNameFor).sorted().collect(toImmutableList()));
-    }
-
-    setChangeSubjectHeader();
-    if (email.getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)
-        || email.getNotify().handling().equals(NotifyHandling.ALL)) {
-      try {
-        this.changeData.reviewersByEmail().byState(ReviewerStateInternal.CC).stream()
-            .forEach(address -> email.addByEmail(RecipientType.CC, address));
-        this.changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER).stream()
-            .forEach(address -> email.addByEmail(RecipientType.CC, address));
-      } catch (StorageException e) {
-        throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
-      }
-    }
-
-    if (email.useHtml()) {
-      email.appendHtml(email.soyHtmlTemplate("ChangeHeaderHtml"));
-    }
-    email.appendText(email.textTemplate("ChangeHeader"));
-    changeEmailDecorator.populateEmailContent();
-    email.appendText(email.textTemplate("ChangeFooter"));
-    if (email.useHtml()) {
-      email.appendHtml(email.soyHtmlTemplate("ChangeFooterHtml"));
-    }
-  }
-
-  /**
-   * A shortened subject is the subject limited to 72 characters, with an ellipsis if it exceeds
-   * that limit.
-   */
-  private static String shortenSubject(String subject) {
-    if (subject.length() < 73) {
-      return subject;
-    }
-    return subject.substring(0, 69) + "...";
-  }
-
-  private Set<String> getEmailsByState(ReviewerStateInternal state) {
-    Set<String> reviewers = new TreeSet<>();
-    try {
-      for (Account.Id who : changeData.reviewers().byState(state)) {
-        reviewers.add(email.getNameEmailFor(who));
-      }
-    } catch (StorageException e) {
-      logger.atWarning().withCause(e).log("Cannot get change reviewers");
-    }
-    return reviewers;
-  }
-
-  private Set<Account.Id> getAttentionSet() {
-    Set<Account.Id> attentionSet = new TreeSet<>();
-    try {
-      attentionSet =
-          additionsOnly(changeData.attentionSet()).stream()
-              .map(AttentionSetUpdate::account)
-              .collect(Collectors.toSet());
-    } catch (StorageException e) {
-      logger.atWarning().withCause(e).log("Cannot get change attention set");
-    }
-    return attentionSet;
-  }
-
-  public boolean getIncludeDiff() {
-    return args.settings.includeDiff;
-  }
-
-  private static final int HEAP_EST_SIZE = 32 * 1024;
-
-  /** Show patch set as unified difference. */
-  public String getUnifiedDiff() {
-    Map<String, FileDiffOutput> modifiedFiles;
-    modifiedFiles = listModifiedFiles();
-    if (modifiedFiles.isEmpty()) {
-      // Octopus merges are not well supported for diff output by Gerrit.
-      // Currently these always have a null oldId in the PatchList.
-      return "[Empty change (potentially Octopus merge); cannot be formatted as a diff.]\n";
-    }
-
-    int maxSize = args.settings.maximumDiffSize;
-    TemporaryBuffer.Heap buf = new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxSize), maxSize);
-    try (DiffFormatter fmt = new DiffFormatter(buf)) {
-      try (Repository git = args.server.openRepository(change.getProject())) {
-        try {
-          ObjectId oldId = modifiedFiles.values().iterator().next().oldCommitId();
-          ObjectId newId = modifiedFiles.values().iterator().next().newCommitId();
-          if (oldId.equals(ObjectId.zeroId())) {
-            // DiffOperations returns ObjectId.zeroId if newCommit is a root commit, i.e. has no
-            // parents.
-            oldId = null;
-          }
-          fmt.setRepository(git);
-          fmt.setDetectRenames(true);
-          fmt.format(oldId, newId);
-          return RawParseUtils.decode(buf.toByteArray());
-        } catch (IOException e) {
-          if (JGitText.get().inMemoryBufferLimitExceeded.equals(e.getMessage())) {
-            return "";
-          }
-          logger.atSevere().withCause(e).log("Cannot format patch");
-          return "";
-        }
-      } catch (IOException e) {
-        logger.atSevere().withCause(e).log("Cannot open repository to format patch");
-        return "";
-      }
-    }
-  }
-
-  /**
-   * Generate a list of maps representing each line of the unified diff. The line maps will have a
-   * 'type' key which maps to one of 'common', 'add' or 'remove' and a 'text' key which maps to the
-   * line's content.
-   *
-   * @param sourceDiff the unified diff that we're converting to the map.
-   * @return map of 'type' to a line's content.
-   */
-  public static ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(String sourceDiff) {
-    ImmutableList.Builder<ImmutableMap<String, String>> result = ImmutableList.builder();
-    Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
-    for (String diffLine : lineSplitter.split(sourceDiff)) {
-      ImmutableMap.Builder<String, String> lineData = ImmutableMap.builder();
-      lineData.put("text", diffLine);
-
-      // Skip empty lines and lines that look like diff headers.
-      if (diffLine.isEmpty() || diffLine.startsWith("---") || diffLine.startsWith("+++")) {
-        lineData.put("type", "common");
-      } else {
-        switch (diffLine.charAt(0)) {
-          case '+':
-            lineData.put("type", "add");
-            break;
-          case '-':
-            lineData.put("type", "remove");
-            break;
-          default:
-            lineData.put("type", "common");
-            break;
-        }
-      }
-      result.add(lineData.build());
-    }
-    return result.build();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecorator.java
index 932e120..48b2257 100644
--- a/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecorator.java
@@ -46,7 +46,7 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.receive.Protocol;
-import com.google.gerrit.server.mail.send.ChangeEmailNew.ChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.util.LabelVote;
@@ -85,19 +85,28 @@
       return args.urlFormatter
           .get()
           .getInlineCommentView(changeEmail.getChange(), uuid)
+          .map(EmailArguments::addUspParam)
           .orElse(null);
     }
 
     /** Returns a web link to the comment tab view of a change. */
     @Nullable
     public String getCommentsTabLink() {
-      return args.urlFormatter.get().getCommentsTabView(changeEmail.getChange()).orElse(null);
+      return args.urlFormatter
+          .get()
+          .getCommentsTabView(changeEmail.getChange())
+          .map(EmailArguments::addUspParam)
+          .orElse(null);
     }
 
     /** Returns a web link to the findings tab view of a change. */
     @Nullable
     public String getFindingsTabLink() {
-      return args.urlFormatter.get().getFindingsTabView(changeEmail.getChange()).orElse(null);
+      return args.urlFormatter
+          .get()
+          .getFindingsTabView(changeEmail.getChange())
+          .map(EmailArguments::addUspParam)
+          .orElse(null);
     }
 
     /**
@@ -117,8 +126,8 @@
   }
 
   private EmailArguments args;
-  private OutgoingEmailNew email;
-  private ChangeEmailNew changeEmail;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
   private List<? extends Comment> inlineComments = Collections.emptyList();
   @Nullable private String patchSetComment;
   private ImmutableList<LabelVote> labels = ImmutableList.of();
@@ -166,7 +175,7 @@
   }
 
   @Override
-  public void init(OutgoingEmailNew email, ChangeEmailNew changeEmail) {
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
     this.email = email;
     this.changeEmail = changeEmail;
     // Add header that enables identifying comments on parsed email.
diff --git a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
deleted file mode 100644
index 4667652..0000000
--- a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Notify interested parties of a brand new change. */
-public class CreateChangeSender extends NewChangeSender {
-  public interface Factory {
-    CreateChangeSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public CreateChangeSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-
-    includeWatchers(
-        NotifyType.NEW_CHANGES, !getChange().isWorkInProgress() && !getChange().isPrivate());
-    includeWatchers(
-        NotifyType.NEW_PATCHSETS, !getChange().isWorkInProgress() && !getChange().isPrivate());
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeyEmailDecorator.java b/java/com/google/gerrit/server/mail/send/DeleteKeyEmailDecorator.java
new file mode 100644
index 0000000..b2228f5
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeyEmailDecorator.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2019 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.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import java.util.Collections;
+import java.util.List;
+
+/** Informs a user by email about the removal of an SSH or GPG key from their account. */
+@AutoFactory
+public class DeleteKeyEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+
+  private final IdentifiedUser user;
+  private final AccountSshKey sshKey;
+  private final List<String> gpgKeyFingerprints;
+  private final MessageIdGenerator messageIdGenerator;
+
+  public DeleteKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, AccountSshKey sshKey) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.gpgKeyFingerprints = Collections.emptyList();
+    this.sshKey = sshKey;
+  }
+
+  public DeleteKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator,
+      IdentifiedUser user,
+      List<String> gpgKeyFingerprints) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.gpgKeyFingerprints = gpgKeyFingerprints;
+    this.sshKey = null;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
+    email.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+    email.addByAccountId(RecipientType.TO, user.getAccountId());
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("email", getEmail());
+    email.addSoyEmailDataParam("gpgKeyFingerprints", getGpgKeyFingerprints());
+    email.addSoyEmailDataParam("keyType", getKeyType());
+    email.addSoyEmailDataParam("sshKey", getSshKey());
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("sshKeysSettingsUrl", email.getSettingsUrl("ssh-keys"));
+    email.addSoyEmailDataParam("gpgKeysSettingsUrl", email.getSettingsUrl("gpg-keys"));
+
+    email.appendText(email.textTemplate("DeleteKey"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("DeleteKeyHtml"));
+    }
+  }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+
+  private String getKeyType() {
+    if (sshKey != null) {
+      return "SSH";
+    } else if (gpgKeyFingerprints != null) {
+      return "GPG";
+    }
+    throw new IllegalStateException("key type is not SSH or GPG");
+  }
+
+  @Nullable
+  private String getSshKey() {
+    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
+  }
+
+  @Nullable
+  private String getGpgKeyFingerprints() {
+    if (!gpgKeyFingerprints.isEmpty()) {
+      return Joiner.on("\n").join(gpgKeyFingerprints);
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
deleted file mode 100644
index e9a2ead..0000000
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ /dev/null
@@ -1,125 +0,0 @@
-// Copyright (C) 2019 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.send;
-
-import com.google.common.base.Joiner;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountSshKey;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * Sender that informs a user by email about the removal of an SSH or GPG key from their account.
- */
-public class DeleteKeySender extends OutgoingEmail {
-  public interface Factory {
-    DeleteKeySender create(IdentifiedUser user, AccountSshKey sshKey);
-
-    DeleteKeySender create(IdentifiedUser user, List<String> gpgKeyFingerprints);
-  }
-
-  private final IdentifiedUser user;
-  private final AccountSshKey sshKey;
-  private final List<String> gpgKeyFingerprints;
-  private final MessageIdGenerator messageIdGenerator;
-
-  @AssistedInject
-  public DeleteKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted AccountSshKey sshKey) {
-    super(args, "deletekey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.gpgKeyFingerprints = Collections.emptyList();
-    this.sshKey = sshKey;
-  }
-
-  @AssistedInject
-  public DeleteKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted List<String> gpgKeyFingerprints) {
-    super(args, "deletekey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.gpgKeyFingerprints = gpgKeyFingerprints;
-    this.sshKey = null;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
-    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-    addByAccountId(RecipientType.TO, user.getAccountId());
-  }
-
-  @Override
-  protected boolean shouldSendMessage() {
-    return true;
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("DeleteKey"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("DeleteKeyHtml"));
-    }
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyEmailDataParam("email", getEmail());
-    addSoyEmailDataParam("gpgKeyFingerprints", getGpgKeyFingerprints());
-    addSoyEmailDataParam("keyType", getKeyType());
-    addSoyEmailDataParam("sshKey", getSshKey());
-    addSoyEmailDataParam("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-  }
-
-  private String getEmail() {
-    return user.getAccount().preferredEmail();
-  }
-
-  private String getKeyType() {
-    if (sshKey != null) {
-      return "SSH";
-    } else if (gpgKeyFingerprints != null) {
-      return "GPG";
-    }
-    throw new IllegalStateException("key type is not SSH or GPG");
-  }
-
-  @Nullable
-  private String getSshKey() {
-    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
-  }
-
-  @Nullable
-  private String getGpgKeyFingerprints() {
-    if (!gpgKeyFingerprints.isEmpty()) {
-      return Joiner.on("\n").join(gpgKeyFingerprints);
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecorator.java
index aa6aad2..5b8ac5d 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecorator.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.mail.send.ChangeEmailNew.ChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
@@ -28,8 +28,8 @@
 
 /** Let users know that a reviewer and possibly her review have been removed. */
 public class DeleteReviewerChangeEmailDecorator implements ChangeEmailDecorator {
-  private OutgoingEmailNew email;
-  private ChangeEmailNew changeEmail;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
 
   private final Set<Account.Id> reviewers = new HashSet<>();
   private final Set<Address> reviewersByEmail = new HashSet<>();
@@ -58,7 +58,7 @@
   }
 
   @Override
-  public void init(OutgoingEmailNew email, ChangeEmailNew changeEmail) {
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
     this.email = email;
     this.changeEmail = changeEmail;
     changeEmail.markAsReply();
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java
index 2bf67e5..873db91 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java
@@ -16,15 +16,15 @@
 
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.mail.send.ChangeEmailNew.ChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 
 /** Send notice about a vote that was removed from a change. */
 public class DeleteVoteChangeEmailDecorator implements ChangeEmailDecorator {
-  private OutgoingEmailNew email;
-  private ChangeEmailNew changeEmail;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
 
   @Override
-  public void init(OutgoingEmailNew email, ChangeEmailNew changeEmail) {
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
     this.email = email;
     this.changeEmail = changeEmail;
     changeEmail.markAsReply();
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 593c718..f77b2c4 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -51,7 +52,10 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.template.soy.jbcsrc.api.SoySauce;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.List;
+import org.apache.http.client.utils.URIBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -59,11 +63,10 @@
 /**
  * Arguments used for sending notification emails.
  *
- * <p>Notification emails are sent by out by {@link OutgoingEmail} and it's subclasses, so called
- * senders. To construct an email the sender class needs to get various other classes injected.
- * Instead of injecting these classes into the sender classes directly, they only get {@code
- * EmailArguments} injected and {@code EmailArguments} provides them all dependencies that they
- * need.
+ * <p>Notification emails are sent by out by {@link OutgoingEmail} . To construct an email class (or
+ * its decorators) needs to get various other classes injected. Instead of injecting these classes
+ * into the sender classes directly, they only get {@code EmailArguments} injected and {@code
+ * EmailArguments} provides them all dependencies that they need.
  *
  * <p>This class is public because plugins need access to it for sending emails.
  */
@@ -177,4 +180,14 @@
   public ChangeData newChangeData(Project.NameKey project, Change.Id id, ObjectId metaId) {
     return changeDataFactory.create(changeNotesFactory.createChecked(project, id, metaId));
   }
+
+  @Nullable
+  public static String addUspParam(String url) {
+    try {
+      URI uri = new URIBuilder(url).addParameter("usp", "email").build();
+      return uri.toString();
+    } catch (URISyntaxException e) {
+      return null;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java
new file mode 100644
index 0000000..af265a6
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2019 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.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import com.google.gerrit.server.util.time.TimeUtil;
+
+/** Sender that informs a user by email that the HTTP password of their account was updated. */
+@AutoFactory
+public class HttpPasswordUpdateEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+
+  private final IdentifiedUser user;
+  private final String operation;
+  private final MessageIdGenerator messageIdGenerator;
+
+  public HttpPasswordUpdateEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, String operation) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.operation = operation;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
+    email.setMessageId(
+        messageIdGenerator.fromReasonAccountIdAndTimestamp(
+            "HTTP_password_change", user.getAccountId(), TimeUtil.now()));
+    email.addByAccountId(RecipientType.TO, user.getAccountId());
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("email", getEmail());
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("operation", operation);
+    email.addSoyEmailDataParam("httpPasswordSettingsUrl", email.getSettingsUrl("http-password"));
+
+    email.appendText(email.textTemplate("HttpPasswordUpdate"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("HttpPasswordUpdateHtml"));
+    }
+  }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
deleted file mode 100644
index 0564355..0000000
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2019 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.send;
-
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-
-/** Sender that informs a user by email that the HTTP password of their account was updated. */
-public class HttpPasswordUpdateSender extends OutgoingEmail {
-  public interface Factory {
-    HttpPasswordUpdateSender create(IdentifiedUser user, String operation);
-  }
-
-  private final IdentifiedUser user;
-  private final String operation;
-  private final MessageIdGenerator messageIdGenerator;
-
-  @AssistedInject
-  public HttpPasswordUpdateSender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted String operation) {
-    super(args, "HttpPasswordUpdate");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.operation = operation;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
-    setMessageId(
-        messageIdGenerator.fromReasonAccountIdAndTimestamp(
-            "HTTP_password_change", user.getAccountId(), TimeUtil.now()));
-    addByAccountId(RecipientType.TO, user.getAccountId());
-  }
-
-  @Override
-  protected boolean shouldSendMessage() {
-    // Always send an email if the HTTP password is updated.
-    return true;
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("HttpPasswordUpdate"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("HttpPasswordUpdateHtml"));
-    }
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyEmailDataParam("email", getEmail());
-    addSoyEmailDataParam("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-    addSoyEmailDataParam("operation", operation);
-  }
-
-  private String getEmail() {
-    return user.getAccount().preferredEmail();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionEmailDecorator.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionEmailDecorator.java
new file mode 100644
index 0000000..1f8fd78
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionEmailDecorator.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2018 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.send;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.MailHeader;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import org.apache.james.mime4j.dom.field.FieldName;
+
+/** Send an email to inform users that parsing their inbound email failed. */
+public class InboundEmailRejectionEmailDecorator implements EmailDecorator {
+
+  /** Used by the templating system to determine what error message should be sent */
+  public enum InboundEmailError {
+    PARSING_ERROR,
+    INACTIVE_ACCOUNT,
+    UNKNOWN_ACCOUNT,
+    INTERNAL_EXCEPTION,
+    COMMENT_REJECTED,
+    CHANGE_NOT_FOUND
+  }
+
+  private OutgoingEmail email;
+  private final Address to;
+  private final InboundEmailError reason;
+  private final String threadId;
+
+  public InboundEmailRejectionEmailDecorator(
+      Address to, String threadId, InboundEmailError reason) {
+    this.to = requireNonNull(to);
+    this.threadId = requireNonNull(threadId);
+    this.reason = requireNonNull(reason);
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    setListIdHeader();
+    email.setHeader(FieldName.SUBJECT, "[Gerrit Code Review] Unable to process your email");
+
+    if (!threadId.isEmpty()) {
+      email.setHeader(MailHeader.REFERENCES.fieldName(), threadId);
+    }
+  }
+
+  private void setListIdHeader() {
+    // Set a reasonable list id so that filters can be used to sort messages
+    email.setHeader("List-Id", "<gerrit-noreply." + email.getGerritHost() + ">");
+    if (email.getSettingsUrl() != null) {
+      email.setHeader("List-Unsubscribe", "<" + email.getSettingsUrl() + ">");
+    }
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addByEmail(RecipientType.TO, to);
+
+    email.appendText(email.textTemplate("InboundEmailRejection_" + reason.name()));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("InboundEmailRejectionHtml_" + reason.name()));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
deleted file mode 100644
index 3a91fe6..0000000
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (C) 2018 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.send;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.MailHeader;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import org.apache.james.mime4j.dom.field.FieldName;
-
-/** Send an email to inform users that parsing their inbound email failed. */
-public class InboundEmailRejectionSender extends OutgoingEmail {
-
-  /** Used by the templating system to determine what error message should be sent */
-  public enum InboundEmailError {
-    PARSING_ERROR,
-    INACTIVE_ACCOUNT,
-    UNKNOWN_ACCOUNT,
-    INTERNAL_EXCEPTION,
-    COMMENT_REJECTED,
-    CHANGE_NOT_FOUND
-  }
-
-  public interface Factory {
-    InboundEmailRejectionSender create(Address to, String threadId, InboundEmailError reason);
-  }
-
-  private final Address to;
-  private final InboundEmailError reason;
-  private final String threadId;
-
-  @Inject
-  public InboundEmailRejectionSender(
-      EmailArguments args,
-      @Assisted Address to,
-      @Assisted String threadId,
-      @Assisted InboundEmailError reason) {
-    super(args, "error");
-    this.to = requireNonNull(to);
-    this.threadId = requireNonNull(threadId);
-    this.reason = requireNonNull(reason);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setListIdHeader();
-    setHeader(FieldName.SUBJECT, "[Gerrit Code Review] Unable to process your email");
-
-    if (!threadId.isEmpty()) {
-      setHeader(MailHeader.REFERENCES.fieldName(), threadId);
-    }
-  }
-
-  private void setListIdHeader() {
-    // Set a reasonable list id so that filters can be used to sort messages
-    setHeader("List-Id", "<gerrit-noreply." + getGerritHost() + ">");
-    if (getSettingsUrl() != null) {
-      setHeader("List-Unsubscribe", "<" + getSettingsUrl() + ">");
-    }
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("InboundEmailRejection_" + reason.name()));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("InboundEmailRejectionHtml_" + reason.name()));
-    }
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addByEmail(RecipientType.TO, to);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index 0eaafb8..7bc319f 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -65,6 +65,8 @@
     "DeleteReviewerHtml.soy",
     "DeleteVote.soy",
     "DeleteVoteHtml.soy",
+    "Email.soy",
+    "EmailHtml.soy",
     "InboundEmailRejection.soy",
     "InboundEmailRejectionHtml.soy",
     "Footer.soy",
diff --git a/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
index dc80ba9..90c8b93 100644
--- a/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.server.change.NotifyResolver;
-import com.google.gerrit.server.mail.send.ChangeEmailNew.ChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 import java.util.Optional;
 
 /** Send notice about a change successfully merged. */
@@ -37,8 +37,8 @@
 public class MergedChangeEmailDecorator implements ChangeEmailDecorator {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private OutgoingEmailNew email;
-  private ChangeEmailNew changeEmail;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
   private LabelTypes labelTypes;
   private final EmailArguments args;
   private final Optional<String> stickyApprovalDiff;
@@ -49,7 +49,7 @@
   }
 
   @Override
-  public void init(OutgoingEmailNew email, ChangeEmailNew changeEmail) {
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
     this.email = email;
     this.changeEmail = changeEmail;
     changeEmail.markAsReply();
@@ -137,7 +137,7 @@
     if (stickyApprovalDiff.isPresent()) {
       email.addSoyEmailDataParam("stickyApprovalDiff", stickyApprovalDiff.get());
       email.addSoyEmailDataParam(
-          "stickyApprovalDiffHtml", ChangeEmailNew.getDiffTemplateData(stickyApprovalDiff.get()));
+          "stickyApprovalDiffHtml", ChangeEmail.getDiffTemplateData(stickyApprovalDiff.get()));
     }
 
     changeEmail.addAuthors(RecipientType.TO);
diff --git a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
deleted file mode 100644
index c073805..0000000
--- a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Asks a user to review a change. */
-public class ModifyReviewerSender extends NewChangeSender {
-  public interface Factory {
-    ModifyReviewerSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public ModifyReviewerSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-
-    ccExistingReviewers();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
deleted file mode 100644
index 2be5797..0000000
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ /dev/null
@@ -1,133 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/** Sends an email alerting a user to a new change for them to review. */
-public abstract class NewChangeSender extends ChangeEmail {
-  private final Set<Account.Id> reviewers = new HashSet<>();
-  private final Set<Address> reviewersByEmail = new HashSet<>();
-  private final Set<Account.Id> extraCC = new HashSet<>();
-  private final Set<Address> extraCCByEmail = new HashSet<>();
-  private final Set<Account.Id> removedReviewers = new HashSet<>();
-  private final Set<Address> removedByEmailReviewers = new HashSet<>();
-
-  protected NewChangeSender(EmailArguments args, ChangeData changeData) {
-    super(args, "newchange", changeData);
-  }
-
-  public void addReviewers(Collection<Account.Id> cc) {
-    reviewers.addAll(cc);
-  }
-
-  public void addReviewersByEmail(Collection<Address> cc) {
-    reviewersByEmail.addAll(cc);
-  }
-
-  public void addExtraCC(Collection<Account.Id> cc) {
-    extraCC.addAll(cc);
-  }
-
-  public void addExtraCCByEmail(Collection<Address> cc) {
-    extraCCByEmail.addAll(cc);
-  }
-
-  public void addRemovedReviewers(Collection<Account.Id> removed) {
-    removedReviewers.addAll(removed);
-  }
-
-  public void addRemovedByEmailReviewers(Collection<Address> removed) {
-    removedByEmailReviewers.addAll(removed);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    String threadId = getChangeMessageThreadId();
-    setHeader("References", threadId);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("NewChange"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("NewChangeHtml"));
-    }
-  }
-
-  @Nullable
-  private List<String> getReviewerNames() {
-    if (reviewers.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : reviewers) {
-      names.add(getNameFor(id));
-    }
-    return names;
-  }
-
-  @Nullable
-  private List<String> getRemovedReviewerNames() {
-    if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : removedReviewers) {
-      names.add(getNameFor(id));
-    }
-    for (Address address : removedByEmailReviewers) {
-      names.add(address.toString());
-    }
-    return names;
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyParam("ownerName", getNameFor(getChange().getOwner()));
-    addSoyEmailDataParam("reviewerNames", getReviewerNames());
-    addSoyEmailDataParam("removedReviewerNames", getRemovedReviewerNames());
-
-    switch (getNotify().handling()) {
-      case NONE:
-      case OWNER:
-        break;
-      case ALL:
-      default:
-        extraCC.stream().forEach(cc -> addByAccountId(RecipientType.CC, cc));
-        extraCCByEmail.stream().forEach(cc -> addByEmail(RecipientType.CC, cc));
-        // $FALL-THROUGH$
-      case OWNER_REVIEWERS:
-        reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r, true));
-        reviewersByEmail.stream().forEach(r -> addByEmail(RecipientType.TO, r, true));
-        removedReviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r, true));
-        removedByEmailReviewers.stream().forEach(r -> addByEmail(RecipientType.TO, r, true));
-        break;
-    }
-    addAuthors(RecipientType.CC);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index d432ab8..ca19150 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -18,6 +18,8 @@
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED;
 import static java.util.Objects.requireNonNull;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -39,6 +41,8 @@
 import com.google.gerrit.server.update.RetryableAction.ActionType;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
+import com.google.template.soy.data.SanitizedContent.ContentKind;
+import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
 import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.net.MalformedURLException;
 import java.net.URL;
@@ -57,8 +61,49 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.SystemReader;
 
-/** Sends an email to one or more interested parties. */
-public abstract class OutgoingEmail {
+/** Represents an email notification for some event that can be sent to interested parties. */
+@AutoFactory
+public final class OutgoingEmail {
+
+  /** Provides content, recipients and any customizations of the email. */
+  public interface EmailDecorator {
+    /**
+     * Stores the reference to the email for the subsequent calls.
+     *
+     * <p>Both init and populateEmailContent can be called multiply times in case of retries. Init
+     * is therefore responsible for clearing up any changes which are not idempotent and
+     * initializing data for use in populateEmailContent.
+     *
+     * <p>Can be used to adjust any of the behaviour of the {@link
+     * OutgoingEmail#populateEmailContent}.
+     */
+    void init(OutgoingEmail email) throws EmailException;
+
+    /**
+     * Populate headers, recipients and body of the email.
+     *
+     * <p>Method operates on the email provided in the init method.
+     *
+     * <p>By default, all the contents and parameters of the email should be set in this method.
+     */
+    void populateEmailContent() throws EmailException;
+
+    /** If returns false email is not sent to any recipients. */
+    default boolean shouldSendMessage() throws EmailException {
+      return true;
+    }
+
+    /** Evaluates whether account can be added to the list of recipients. */
+    default boolean isRecipientAllowed(Account.Id rcpt) throws PermissionBackendException {
+      return true;
+    }
+
+    /** Evaluates whether email can be added to the list of recipients. */
+    default boolean isRecipientAllowed(Address rcpt) throws PermissionBackendException {
+      return true;
+    }
+  }
+
   private static final String SOY_TEMPLATE_NAMESPACE = "com.google.gerrit.server.mail.template";
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -74,19 +119,24 @@
   private Map<String, Object> soyContext;
   private Map<String, Object> soyContextEmailData;
   private List<String> footers;
-  protected final EmailArguments args;
+  private final EmailArguments args;
   private Account.Id fromId;
   private NotifyResolver.Result notify = NotifyResolver.Result.all();
+  private final EmailDecorator templateProvider;
 
-  protected OutgoingEmail(EmailArguments args, String messageClass) {
+  public OutgoingEmail(
+      @Provided EmailArguments args, String messageClass, EmailDecorator templateProvider) {
     this.args = args;
     this.messageClass = messageClass;
+    this.templateProvider = templateProvider;
   }
 
+  /** Specify the account that triggered the notification. */
   public void setFrom(Account.Id id) {
     fromId = id;
   }
 
+  /** Get the account that triggered the notification. */
   public Account.Id getFrom() {
     return fromId;
   }
@@ -101,10 +151,27 @@
     return this.notify;
   }
 
+  /** Set identifier for the email. Every email must have one. */
   public void setMessageId(MessageIdGenerator.MessageId messageId) {
     this.messageId = messageId;
   }
 
+  private String constructTextEmail() {
+    soyContext.put("body", textBody.toString());
+    soyContext.put("footer", textTemplate("Footer"));
+    return textTemplate("Email");
+  }
+
+  private String constructHtmlEmail() {
+    soyContext.put(
+        "body", UnsafeSanitizedContentOrdainer.ordainAsSafe(htmlBody.toString(), ContentKind.HTML));
+    soyContext.put(
+        "footer",
+        UnsafeSanitizedContentOrdainer.ordainAsSafe(
+            soyHtmlTemplate("FooterHtml"), ContentKind.HTML));
+    return soyHtmlTemplate("EmailHtml");
+  }
+
   /** Format and enqueue the message for delivery. */
   public void send() throws EmailException {
     try {
@@ -143,11 +210,6 @@
     if (messageId == null) {
       throw new IllegalStateException("All emails must have a messageId");
     }
-    format();
-    appendText(textTemplate("Footer"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("FooterHtml"));
-    }
 
     Set<Address> smtpRcptToPlaintextOnly = new HashSet<>();
     if (shouldSendMessage()) {
@@ -247,16 +309,15 @@
         setHeader(FieldName.REPLY_TO, j.toString());
       }
 
-      String textPart = textBody.toString();
       OutgoingEmailValidationListener.Args va = new OutgoingEmailValidationListener.Args();
       va.messageClass = messageClass;
       va.smtpFromAddress = smtpFromAddress;
       va.smtpRcptTo = smtpRcptTo;
       va.headers = headers;
-      va.body = textPart;
+      va.body = constructTextEmail();
 
       if (useHtml()) {
-        va.htmlBody = htmlBody.toString();
+        va.htmlBody = constructHtmlEmail();
       } else {
         va.htmlBody = null;
       }
@@ -284,7 +345,7 @@
         shallowCopy.remove(FieldName.CC);
         for (Address a : smtpRcptToPlaintextOnly) {
           // Add new To
-          EmailHeader.AddressList to = new EmailHeader.AddressList();
+          AddressList to = new AddressList();
           to.add(a);
           shallowCopy.put(FieldName.TO, to);
         }
@@ -320,24 +381,21 @@
     }
   }
 
-  /** Format the message body by calling {@link #appendText(String)}. */
-  protected abstract void format() throws EmailException;
-
   /**
    * Setup the message headers and envelope (TO, CC, BCC).
    *
    * @throws EmailException if an error occurred.
    */
-  protected void init() throws EmailException {
+  public void init() throws EmailException {
     soyContext = new HashMap<>();
     footers = new ArrayList<>();
     soyContextEmailData = new HashMap<>();
 
     smtpFromAddress = args.fromAddressGenerator.get().from(fromId);
     setHeader(FieldName.DATE, Instant.now());
-    headers.put(FieldName.FROM, new EmailHeader.AddressList(smtpFromAddress));
-    headers.put(FieldName.TO, new EmailHeader.AddressList());
-    headers.put(FieldName.CC, new EmailHeader.AddressList());
+    headers.put(FieldName.FROM, new AddressList(smtpFromAddress));
+    headers.put(FieldName.TO, new AddressList());
+    headers.put(FieldName.CC, new AddressList());
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
     setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
@@ -348,9 +406,11 @@
     if (fromId != null && args.fromAddressGenerator.get().isGenericAddress(fromId)) {
       appendText(getFromLine());
     }
+
+    templateProvider.init(this);
   }
 
-  protected String getFromLine() {
+  private String getFromLine() {
     StringBuilder f = new StringBuilder();
     Optional<Account> account = args.accountCache.get(fromId).map(AccountState::account);
     if (account.isPresent()) {
@@ -371,9 +431,10 @@
   }
 
   public String getGerritHost() {
-    if (getGerritUrl() != null) {
+    Optional<String> gerritUrl = args.urlFormatter.get().getWebUrl();
+    if (gerritUrl.isPresent()) {
       try {
-        return new URL(getGerritUrl()).getHost();
+        return new URL(gerritUrl.get()).getHost();
       } catch (MalformedURLException e) {
         // Try something else.
       }
@@ -388,44 +449,49 @@
 
   @Nullable
   public String getSettingsUrl() {
-    return args.urlFormatter.get().getSettingsUrl().orElse(null);
+    return args.urlFormatter.get().getSettingsUrl().map(EmailArguments::addUspParam).orElse(null);
   }
 
   @Nullable
-  private String getGerritUrl() {
-    return args.urlFormatter.get().getWebUrl().orElse(null);
+  public String getSettingsUrl(String section) {
+    return args.urlFormatter
+        .get()
+        .getSettingsUrl(section)
+        .map(EmailArguments::addUspParam)
+        .orElse(null);
   }
 
   /** Set a header in the outgoing message. */
-  protected void setHeader(String name, String value) {
+  public void setHeader(String name, String value) {
     headers.put(name, new StringEmailHeader(value));
   }
 
   /** Remove a header from the outgoing message. */
-  protected void removeHeader(String name) {
+  public void removeHeader(String name) {
     headers.remove(name);
   }
 
-  protected void setHeader(String name, Instant date) {
+  /** Set a date header in the outgoing message. */
+  public void setHeader(String name, Instant date) {
     headers.put(name, new EmailHeader.Date(date));
   }
 
   /** Append text to the outgoing email body. */
-  protected void appendText(String text) {
+  public void appendText(String text) {
     if (text != null) {
       textBody.append(text);
     }
   }
 
   /** Append html to the outgoing email body. */
-  protected void appendHtml(String html) {
+  public void appendHtml(String html) {
     if (html != null) {
       htmlBody.append(html);
     }
   }
 
   /** Lookup a human readable name for an account, usually the "full name". */
-  protected String getNameFor(@Nullable Account.Id accountId) {
+  public String getNameFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       return args.gerritPersonIdent.get().getName();
     }
@@ -451,7 +517,7 @@
    * @param accountId user to fetch.
    * @return name/email of account, or Anonymous Coward if unset.
    */
-  protected String getNameEmailFor(@Nullable Account.Id accountId) {
+  public String getNameEmailFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       PersonIdent gerritIdent = args.gerritPersonIdent.get();
       return gerritIdent.getName() + " <" + gerritIdent.getEmailAddress() + ">";
@@ -480,7 +546,7 @@
    * @return name/email of account, username, or null if unset or the accountId is null.
    */
   @Nullable
-  protected String getUserNameEmailFor(@Nullable Account.Id accountId) {
+  public String getUserNameEmailFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       return null;
     }
@@ -503,7 +569,7 @@
     return accountState.get().userName().orElse(null);
   }
 
-  protected boolean shouldSendMessage() {
+  private boolean shouldSendMessage() throws EmailException {
     if (textBody.length() == 0) {
       // If we have no message body, don't send.
       logger.atFine().log("Not sending '%s': No message body", messageClass);
@@ -528,7 +594,7 @@
       return false;
     }
 
-    return true;
+    return templateProvider.shouldSendMessage();
   }
 
   /**
@@ -566,8 +632,8 @@
    * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
    *     permission backend
    */
-  protected boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
-    return true;
+  public boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
+    return templateProvider.isRecipientAllowed(addr);
   }
 
   /**
@@ -576,7 +642,7 @@
    * @param rt category of recipient (TO, CC, BCC)
    * @param to Gerrit Account of the recipient.
    */
-  protected void addByAccountId(RecipientType rt, Account.Id to) {
+  public void addByAccountId(RecipientType rt, Account.Id to) {
     addByAccountId(rt, to, false);
   }
 
@@ -588,7 +654,7 @@
    * @param override if the recipient was added previously and override is false no change is made
    *     regardless of {@code rt}.
    */
-  protected void addByAccountId(RecipientType rt, Account.Id to, boolean override) {
+  public void addByAccountId(RecipientType rt, Account.Id to, boolean override) {
     try {
       if (!rcptTo.contains(to) && isRecipientAllowed(to)) {
         rcptTo.add(to);
@@ -606,8 +672,8 @@
    * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
    *     permission backend
    */
-  protected boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
-    return true;
+  public boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
+    return templateProvider.isRecipientAllowed(to);
   }
 
   private final void add(RecipientType rt, Address addr, boolean override) {
@@ -619,16 +685,16 @@
           if (!override) {
             return;
           }
-          ((EmailHeader.AddressList) headers.get(FieldName.TO)).remove(addr.email());
-          ((EmailHeader.AddressList) headers.get(FieldName.CC)).remove(addr.email());
+          ((AddressList) headers.get(FieldName.TO)).remove(addr.email());
+          ((AddressList) headers.get(FieldName.CC)).remove(addr.email());
           smtpBccRcptTo.remove(addr);
         }
         switch (rt) {
           case TO:
-            ((EmailHeader.AddressList) headers.get(FieldName.TO)).add(addr);
+            ((AddressList) headers.get(FieldName.TO)).add(addr);
             break;
           case CC:
-            ((EmailHeader.AddressList) headers.get(FieldName.CC)).add(addr);
+            ((AddressList) headers.get(FieldName.CC)).add(addr);
             break;
           case BCC:
             smtpBccRcptTo.add(addr);
@@ -653,14 +719,8 @@
     return Address.create(account.fullName(), e);
   }
 
-  /**
-   * Populate the email content.
-   *
-   * <p>Subclasses may override this method to populate further email content.
-   *
-   * @throws EmailException thrown if there is an error while populating the email content
-   */
-  protected void populateEmailContent() throws EmailException {
+  /** Set recipients, headers, body of the email. */
+  public void populateEmailContent() throws EmailException {
     for (RecipientType recipientType : notify.accounts().keySet()) {
       notify.accounts().get(recipientType).stream().forEach(a -> addByAccountId(recipientType, a));
     }
@@ -670,8 +730,9 @@
     addSoyEmailDataParam("settingsUrl", getSettingsUrl());
     addSoyEmailDataParam("instanceName", getInstanceName());
     addSoyEmailDataParam("gerritHost", getGerritHost());
-    addSoyEmailDataParam("gerritUrl", getGerritUrl());
     addSoyParam("email", soyContextEmailData);
+
+    templateProvider.populateEmailContent();
   }
 
   /** Adds param to the data map passed into soy when rendering templates. */
@@ -697,12 +758,12 @@
   }
 
   /** Renders a soy template of kind="text". */
-  protected String textTemplate(String name) {
+  public String textTemplate(String name) {
     return configureRenderer(name).renderText().get();
   }
 
   /** Renders a soy template of kind="html". */
-  protected String soyHtmlTemplate(String name) {
+  public String soyHtmlTemplate(String name) {
     return configureRenderer(name).renderHtml().get().toString();
   }
 
@@ -726,7 +787,8 @@
     return soySauce.renderTemplate(fullTemplateName).setData(soyContext);
   }
 
-  protected void removeUser(Account user) {
+  /** Remove user from the multipart email recipients. */
+  private void removeUser(Account user) {
     String fromEmail = user.preferredEmail();
     for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) {
       if (j.next().email().equals(fromEmail)) {
@@ -741,7 +803,8 @@
     }
   }
 
-  protected final boolean useHtml() {
+  /** Return true, if the email should include html body. */
+  public boolean useHtml() {
     return args.settings.html;
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmailNew.java b/java/com/google/gerrit/server/mail/send/OutgoingEmailNew.java
deleted file mode 100644
index ae558b7..0000000
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmailNew.java
+++ /dev/null
@@ -1,793 +0,0 @@
-// 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.send;
-
-import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
-import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED;
-import static java.util.Objects.requireNonNull;
-
-import com.google.auto.factory.AutoFactory;
-import com.google.auto.factory.Provided;
-import com.google.common.base.Throwables;
-import com.google.common.collect.Sets;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.entities.EmailHeader;
-import com.google.gerrit.entities.EmailHeader.AddressList;
-import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
-import com.google.gerrit.mail.MailHeader;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.change.NotifyResolver;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.RetryableAction.ActionType;
-import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
-import com.google.gerrit.server.validators.ValidationException;
-import com.google.template.soy.jbcsrc.api.SoySauce;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.StringJoiner;
-import org.apache.james.mime4j.dom.field.FieldName;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.util.SystemReader;
-
-/** Represents an email notification for some event that can be sent to interested parties. */
-@AutoFactory
-public final class OutgoingEmailNew {
-
-  /** Provides content, recipients and any customizations of the email. */
-  public interface EmailDecorator {
-    /**
-     * Stores the reference to the email for the subsequent calls.
-     *
-     * <p>Both init and populateEmailContent can be called multiply times in case of retries. Init
-     * is therefore responsible for clearing up any changes which are not idempotent and
-     * initializing data for use in populateEmailContent.
-     *
-     * <p>Can be used to adjust any of the behaviour of the {@link
-     * OutgoingEmailNew#populateEmailContent}.
-     */
-    void init(OutgoingEmailNew email) throws EmailException;
-
-    /**
-     * Populate headers, recipients and body of the email.
-     *
-     * <p>Method operates on the email provided in the init method.
-     *
-     * <p>By default, all the contents and parameters of the email should be set in this method.
-     */
-    void populateEmailContent() throws EmailException;
-
-    /** If returns false email is not sent to any recipients. */
-    default boolean shouldSendMessage() throws EmailException {
-      return true;
-    }
-
-    /** Evaluates whether account can be added to the list of recipients. */
-    default boolean isRecipientAllowed(Account.Id rcpt) throws PermissionBackendException {
-      return true;
-    }
-
-    /** Evaluates whether email can be added to the list of recipients. */
-    default boolean isRecipientAllowed(Address rcpt) throws PermissionBackendException {
-      return true;
-    }
-  }
-
-  private static final String SOY_TEMPLATE_NAMESPACE = "com.google.gerrit.server.mail.template";
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private String messageClass;
-  private final Set<Account.Id> rcptTo = new HashSet<>();
-  private final Map<String, EmailHeader> headers = new LinkedHashMap<>();;
-  private final Set<Address> smtpRcptTo = new HashSet<>();
-  private final Set<Address> smtpBccRcptTo = new HashSet<>();
-  private Address smtpFromAddress;
-  private StringBuilder textBody;
-  private StringBuilder htmlBody;
-  private MessageIdGenerator.MessageId messageId;
-  private Map<String, Object> soyContext;
-  private Map<String, Object> soyContextEmailData;
-  private List<String> footers;
-  private final EmailArguments args;
-  private Account.Id fromId;
-  private NotifyResolver.Result notify = NotifyResolver.Result.all();
-  private final EmailDecorator templateProvider;
-
-  public OutgoingEmailNew(
-      @Provided EmailArguments args, String messageClass, EmailDecorator templateProvider) {
-    this.args = args;
-    this.messageClass = messageClass;
-    this.templateProvider = templateProvider;
-  }
-
-  /** Specify the account that triggered the notification. */
-  public void setFrom(Account.Id id) {
-    fromId = id;
-  }
-
-  /** Get the account that triggered the notification. */
-  public Account.Id getFrom() {
-    return fromId;
-  }
-
-  /** Set how widely the email notification is allowed to be sent. */
-  public void setNotify(NotifyResolver.Result notify) {
-    this.notify = requireNonNull(notify);
-  }
-
-  /** Returns the setting that controls how widely the email notification is allowed to be sent. */
-  public NotifyResolver.Result getNotify() {
-    return this.notify;
-  }
-
-  /** Set identifier for the email. Every email must have one. */
-  public void setMessageId(MessageIdGenerator.MessageId messageId) {
-    this.messageId = messageId;
-  }
-
-  /** Format and enqueue the message for delivery. */
-  public void send() throws EmailException {
-    try {
-      args.retryHelper
-          .action(
-              ActionType.SEND_EMAIL,
-              "sendEmail",
-              () -> {
-                sendImpl();
-                return null;
-              })
-          .retryWithTrace(Exception.class::isInstance)
-          .call();
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      Throwables.throwIfInstanceOf(e, EmailException.class);
-      throw new EmailException("sending email failed", e);
-    }
-  }
-
-  private void sendImpl() throws EmailException {
-    if (!args.emailSender.isEnabled()) {
-      // Server has explicitly disabled email sending.
-      //
-      logger.atFine().log(
-          "Not sending '%s': Email sending is disabled by server config", messageClass);
-      return;
-    }
-
-    init();
-    if (!notify.shouldNotify()) {
-      logger.atFine().log("Not sending '%s': Notify handling is NONE", messageClass);
-      return;
-    }
-    populateEmailContent();
-    if (messageId == null) {
-      throw new IllegalStateException("All emails must have a messageId");
-    }
-    appendText(textTemplate("Footer"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("FooterHtml"));
-    }
-
-    Set<Address> smtpRcptToPlaintextOnly = new HashSet<>();
-    if (shouldSendMessage()) {
-      if (fromId != null) {
-        Optional<AccountState> fromUser = args.accountCache.get(fromId);
-        if (fromUser.isPresent()) {
-          GeneralPreferencesInfo senderPrefs = fromUser.get().generalPreferences();
-          CurrentUser user = args.currentUserProvider.get();
-          boolean isImpersonating = user.isIdentifiedUser() && user.isImpersonating();
-          if (isImpersonating && user.getAccountId() != fromId) {
-            // This should not be possible, if this is the case it means the RequestContext is not
-            // set up correctly.
-            throw new EmailException(
-                String.format(
-                    "User %s is sending email from %s, while acting on behalf of %s",
-                    user.asIdentifiedUser().getRealUser().getAccountId(),
-                    fromId,
-                    user.getAccountId()));
-          }
-          if (senderPrefs != null && senderPrefs.getEmailStrategy() == CC_ON_OWN_COMMENTS) {
-            // Include the sender in email if they enabled email notifications on their own
-            // comments.
-            //
-            logger.atFine().log(
-                "CC email sender %s because the email strategy of this user is %s",
-                fromUser.get().account().id(), CC_ON_OWN_COMMENTS);
-            addByAccountId(RecipientType.CC, fromId);
-          } else if (isImpersonating) {
-            // If we are impersonating a user, make sure they receive a CC of
-            // this message regardless of email strategy, unless email notifications are explicitly
-            // disabled for this user. This way they can always review and audit what we sent
-            // on their behalf to others.
-            logger.atFine().log(
-                "CC email sender %s because the email is sent on behalf of and email notifications"
-                    + " are enabled for this user.",
-                fromUser.get().account().id());
-            addByAccountId(RecipientType.CC, fromId);
-
-          } else if (!notify.accounts().containsValue(fromId) && rcptTo.remove(fromId)) {
-            // If they don't want a copy, but we queued one up anyway,
-            // drop them from the recipient lists, but only if the user is not being impersonated.
-            //
-            logger.atFine().log(
-                "Not CCing email sender %s because the email strategy of this user is not %s but"
-                    + " %s",
-                fromUser.get().account().id(),
-                CC_ON_OWN_COMMENTS,
-                senderPrefs != null ? senderPrefs.getEmailStrategy() : null);
-            removeUser(fromUser.get().account());
-          }
-        }
-      }
-      // Check the preferences of all recipients. If any user has disabled
-      // his email notifications then drop him from recipients' list.
-      // In addition, check if users only want to receive plaintext email.
-      for (Account.Id id : rcptTo) {
-        Optional<AccountState> thisUser = args.accountCache.get(id);
-        if (thisUser.isPresent()) {
-          Account thisUserAccount = thisUser.get().account();
-          GeneralPreferencesInfo prefs = thisUser.get().generalPreferences();
-          if (prefs == null || prefs.getEmailStrategy() == DISABLED) {
-            logger.atFine().log(
-                "Not emailing account %s because user has set email strategy to %s", id, DISABLED);
-            removeUser(thisUserAccount);
-          } else if (useHtml() && prefs.getEmailFormat() == EmailFormat.PLAINTEXT) {
-            logger.atFine().log(
-                "Removing account %s from HTML email because user prefers plain text emails", id);
-            removeUser(thisUserAccount);
-            smtpRcptToPlaintextOnly.add(
-                Address.create(thisUserAccount.fullName(), thisUserAccount.preferredEmail()));
-          }
-        }
-        if (smtpRcptTo.isEmpty() && smtpRcptToPlaintextOnly.isEmpty()) {
-          logger.atFine().log("Not sending '%s': No SMTP recipients", messageClass);
-          return;
-        }
-      }
-
-      // Set Reply-To only if it hasn't been set by a child class
-      // Reply-To will already be populated for the message types where Gerrit supports
-      // inbound email replies.
-      if (!headers.containsKey(FieldName.REPLY_TO)) {
-        StringJoiner j = new StringJoiner(", ");
-        if (fromId != null) {
-          Address address = toAddress(fromId);
-          if (address != null) {
-            j.add(address.email());
-          }
-        }
-        // For users who prefer plaintext, this comes at the cost of not being
-        // listed in the multipart To and Cc headers. We work around this by adding
-        // all users to the Reply-To address in both the plaintext and multipart
-        // email. We should exclude any BCC addresses from reply-to, because they should be
-        // invisible to other recipients.
-        Sets.difference(Sets.union(smtpRcptTo, smtpRcptToPlaintextOnly), smtpBccRcptTo).stream()
-            .forEach(a -> j.add(a.email()));
-        setHeader(FieldName.REPLY_TO, j.toString());
-      }
-
-      String textPart = textBody.toString();
-      OutgoingEmailValidationListener.Args va = new OutgoingEmailValidationListener.Args();
-      va.messageClass = messageClass;
-      va.smtpFromAddress = smtpFromAddress;
-      va.smtpRcptTo = smtpRcptTo;
-      va.headers = headers;
-      va.body = textPart;
-
-      if (useHtml()) {
-        va.htmlBody = htmlBody.toString();
-      } else {
-        va.htmlBody = null;
-      }
-
-      Set<Address> intersection = Sets.intersection(va.smtpRcptTo, smtpRcptToPlaintextOnly);
-      if (!intersection.isEmpty()) {
-        logger.atSevere().log("Email '%s' will be sent twice to %s", messageClass, intersection);
-      }
-      if (!va.smtpRcptTo.isEmpty()) {
-        // Send multipart message
-        addMessageId(va, "-HTML");
-        if (!validateEmail(va)) return;
-        logger.atFine().log(
-            "Sending multipart '%s' from %s to %s",
-            messageClass, va.smtpFromAddress, va.smtpRcptTo);
-        args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body, va.htmlBody);
-      }
-      if (!smtpRcptToPlaintextOnly.isEmpty()) {
-        addMessageId(va, "-PLAIN");
-        // Send plaintext message
-        Map<String, EmailHeader> shallowCopy = new HashMap<>();
-        shallowCopy.putAll(headers);
-        // Remove To and Cc
-        shallowCopy.remove(FieldName.TO);
-        shallowCopy.remove(FieldName.CC);
-        for (Address a : smtpRcptToPlaintextOnly) {
-          // Add new To
-          AddressList to = new AddressList();
-          to.add(a);
-          shallowCopy.put(FieldName.TO, to);
-        }
-        if (!validateEmail(va)) return;
-        logger.atFine().log(
-            "Sending plaintext '%s' from %s to %s",
-            messageClass, va.smtpFromAddress, smtpRcptToPlaintextOnly);
-        args.emailSender.send(va.smtpFromAddress, smtpRcptToPlaintextOnly, shallowCopy, va.body);
-      }
-    }
-  }
-
-  private boolean validateEmail(OutgoingEmailValidationListener.Args va) {
-    for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
-      try {
-        validator.validateOutgoingEmail(va);
-      } catch (ValidationException e) {
-        logger.atFine().log(
-            "Not sending '%s': Rejected by outgoing email validator: %s",
-            messageClass, e.getMessage());
-        return false;
-      }
-    }
-    return true;
-  }
-
-  // All message ids must start with < and end with >. Also, they must have @domain and no spaces.
-  private void addMessageId(OutgoingEmailValidationListener.Args va, String suffix) {
-    if (messageId != null) {
-      String message = "<" + messageId.id() + suffix + "@" + getGerritHost() + ">";
-      message = message.replaceAll("\\s", "");
-      va.headers.put(FieldName.MESSAGE_ID, new StringEmailHeader(message));
-    }
-  }
-
-  /**
-   * Setup the message headers and envelope (TO, CC, BCC).
-   *
-   * @throws EmailException if an error occurred.
-   */
-  public void init() throws EmailException {
-    soyContext = new HashMap<>();
-    footers = new ArrayList<>();
-    soyContextEmailData = new HashMap<>();
-
-    smtpFromAddress = args.fromAddressGenerator.get().from(fromId);
-    setHeader(FieldName.DATE, Instant.now());
-    headers.put(FieldName.FROM, new AddressList(smtpFromAddress));
-    headers.put(FieldName.TO, new AddressList());
-    headers.put(FieldName.CC, new AddressList());
-    setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
-
-    setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
-    addFooter(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass);
-    textBody = new StringBuilder();
-    htmlBody = new StringBuilder();
-
-    if (fromId != null && args.fromAddressGenerator.get().isGenericAddress(fromId)) {
-      appendText(getFromLine());
-    }
-
-    templateProvider.init(this);
-  }
-
-  private String getFromLine() {
-    StringBuilder f = new StringBuilder();
-    Optional<Account> account = args.accountCache.get(fromId).map(AccountState::account);
-    if (account.isPresent()) {
-      String name = account.get().fullName();
-      String email = account.get().preferredEmail();
-      if ((name != null && !name.isEmpty()) || (email != null && !email.isEmpty())) {
-        f.append("From");
-        if (name != null && !name.isEmpty()) {
-          f.append(" ").append(name);
-        }
-        if (email != null && !email.isEmpty()) {
-          f.append(" <").append(email).append(">");
-        }
-        f.append(":\n\n");
-      }
-    }
-    return f.toString();
-  }
-
-  public String getGerritHost() {
-    if (getGerritUrl() != null) {
-      try {
-        return new URL(getGerritUrl()).getHost();
-      } catch (MalformedURLException e) {
-        // Try something else.
-      }
-    }
-
-    // Fall back onto whatever the local operating system thinks
-    // this server is called. We hopefully didn't get here as a
-    // good admin would have configured the canonical url.
-    //
-    return SystemReader.getInstance().getHostname();
-  }
-
-  @Nullable
-  public String getSettingsUrl() {
-    return args.urlFormatter.get().getSettingsUrl().orElse(null);
-  }
-
-  @Nullable
-  private String getGerritUrl() {
-    return args.urlFormatter.get().getWebUrl().orElse(null);
-  }
-
-  /** Set a header in the outgoing message. */
-  public void setHeader(String name, String value) {
-    headers.put(name, new StringEmailHeader(value));
-  }
-
-  /** Remove a header from the outgoing message. */
-  public void removeHeader(String name) {
-    headers.remove(name);
-  }
-
-  /** Set a date header in the outgoing message. */
-  public void setHeader(String name, Instant date) {
-    headers.put(name, new EmailHeader.Date(date));
-  }
-
-  /** Append text to the outgoing email body. */
-  public void appendText(String text) {
-    if (text != null) {
-      textBody.append(text);
-    }
-  }
-
-  /** Append html to the outgoing email body. */
-  public void appendHtml(String html) {
-    if (html != null) {
-      htmlBody.append(html);
-    }
-  }
-
-  /** Lookup a human readable name for an account, usually the "full name". */
-  public String getNameFor(@Nullable Account.Id accountId) {
-    if (accountId == null) {
-      return args.gerritPersonIdent.get().getName();
-    }
-
-    Optional<Account> account = args.accountCache.get(accountId).map(AccountState::account);
-    String name = null;
-    if (account.isPresent()) {
-      name = account.get().fullName();
-      if (name == null) {
-        name = account.get().preferredEmail();
-      }
-    }
-    if (name == null) {
-      name = args.anonymousCowardName + " #" + accountId;
-    }
-    return name;
-  }
-
-  /**
-   * Gets the human readable name and email for an account; if neither are available, returns the
-   * Anonymous Coward name.
-   *
-   * @param accountId user to fetch.
-   * @return name/email of account, or Anonymous Coward if unset.
-   */
-  public String getNameEmailFor(@Nullable Account.Id accountId) {
-    if (accountId == null) {
-      PersonIdent gerritIdent = args.gerritPersonIdent.get();
-      return gerritIdent.getName() + " <" + gerritIdent.getEmailAddress() + ">";
-    }
-
-    Optional<Account> account = args.accountCache.get(accountId).map(AccountState::account);
-    if (account.isPresent()) {
-      String name = account.get().fullName();
-      String email = account.get().preferredEmail();
-      if (name != null && email != null) {
-        return name + " <" + email + ">";
-      } else if (name != null) {
-        return name;
-      } else if (email != null) {
-        return email;
-      }
-    }
-    return args.anonymousCowardName + " #" + accountId;
-  }
-
-  /**
-   * Gets the human readable name and email for an account; if both are unavailable, returns the
-   * username. If no username is set, this function returns null.
-   *
-   * @param accountId user to fetch.
-   * @return name/email of account, username, or null if unset or the accountId is null.
-   */
-  @Nullable
-  public String getUserNameEmailFor(@Nullable Account.Id accountId) {
-    if (accountId == null) {
-      return null;
-    }
-
-    Optional<AccountState> accountState = args.accountCache.get(accountId);
-    if (!accountState.isPresent()) {
-      return null;
-    }
-
-    Account account = accountState.get().account();
-    String name = account.fullName();
-    String email = account.preferredEmail();
-    if (name != null && email != null) {
-      return name + " <" + email + ">";
-    } else if (email != null) {
-      return email;
-    } else if (name != null) {
-      return name;
-    }
-    return accountState.get().userName().orElse(null);
-  }
-
-  private boolean shouldSendMessage() throws EmailException {
-    if (textBody.length() == 0) {
-      // If we have no message body, don't send.
-      logger.atFine().log("Not sending '%s': No message body", messageClass);
-      return false;
-    }
-
-    if (smtpRcptTo.isEmpty()) {
-      // If we have nobody to send this message to, then all of our
-      // selection filters previously for this type of message were
-      // unable to match a destination. Don't bother sending it.
-      logger.atFine().log("Not sending '%s': No recipients", messageClass);
-      return false;
-    }
-
-    if (notify.accounts().isEmpty()
-        && smtpRcptTo.size() == 1
-        && rcptTo.size() == 1
-        && rcptTo.contains(fromId)) {
-      // If the only recipient is also the sender, don't bother.
-      //
-      logger.atFine().log("Not sending '%s': Sender is only recipient", messageClass);
-      return false;
-    }
-
-    return templateProvider.shouldSendMessage();
-  }
-
-  /**
-   * Adds a recipient that the email will be sent to.
-   *
-   * @param rt category of recipient (TO, CC, BCC)
-   * @param addr Name and email of the recipient.
-   */
-  public final void addByEmail(RecipientType rt, Address addr) {
-    addByEmail(rt, addr, false);
-  }
-
-  /**
-   * Adds a recipient that the email will be sent to.
-   *
-   * @param rt category of recipient (TO, CC, BCC).
-   * @param addr Name and email of the recipient.
-   * @param override if the recipient was added previously and override is false no change is made
-   *     regardless of {@code rt}.
-   */
-  public final void addByEmail(RecipientType rt, Address addr, boolean override) {
-    try {
-      if (isRecipientAllowed(addr)) {
-        add(rt, addr, override);
-      }
-    } catch (PermissionBackendException e) {
-      logger.atSevere().withCause(e).log("Error checking permissions for email address: %s", addr);
-    }
-  }
-
-  /**
-   * Returns whether this email is allowed to be sent to the given address
-   *
-   * @param addr email address of recipient.
-   * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
-   *     permission backend
-   */
-  public boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
-    return templateProvider.isRecipientAllowed(addr);
-  }
-
-  /**
-   * Adds a recipient that the email will be sent to.
-   *
-   * @param rt category of recipient (TO, CC, BCC)
-   * @param to Gerrit Account of the recipient.
-   */
-  public void addByAccountId(RecipientType rt, Account.Id to) {
-    addByAccountId(rt, to, false);
-  }
-
-  /**
-   * Adds a recipient that the email will be sent to.
-   *
-   * @param rt category of recipient (TO, CC, BCC)
-   * @param to Gerrit Account of the recipient.
-   * @param override if the recipient was added previously and override is false no change is made
-   *     regardless of {@code rt}.
-   */
-  public void addByAccountId(RecipientType rt, Account.Id to, boolean override) {
-    try {
-      if (!rcptTo.contains(to) && isRecipientAllowed(to)) {
-        rcptTo.add(to);
-        add(rt, toAddress(to), override);
-      }
-    } catch (PermissionBackendException e) {
-      logger.atSevere().withCause(e).log("Error checking permissions for account: %s", to);
-    }
-  }
-
-  /**
-   * Returns whether this email is allowed to be sent to the given account
-   *
-   * @param to account.
-   * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
-   *     permission backend
-   */
-  public boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
-    return templateProvider.isRecipientAllowed(to);
-  }
-
-  private final void add(RecipientType rt, Address addr, boolean override) {
-    if (addr != null && addr.email() != null && addr.email().length() > 0) {
-      if (!args.validator.isValid(addr.email())) {
-        logger.atWarning().log("Not emailing %s (invalid email address)", addr.email());
-      } else if (args.emailSender.canEmail(addr.email())) {
-        if (!smtpRcptTo.add(addr)) {
-          if (!override) {
-            return;
-          }
-          ((AddressList) headers.get(FieldName.TO)).remove(addr.email());
-          ((AddressList) headers.get(FieldName.CC)).remove(addr.email());
-          smtpBccRcptTo.remove(addr);
-        }
-        switch (rt) {
-          case TO:
-            ((AddressList) headers.get(FieldName.TO)).add(addr);
-            break;
-          case CC:
-            ((AddressList) headers.get(FieldName.CC)).add(addr);
-            break;
-          case BCC:
-            smtpBccRcptTo.add(addr);
-            break;
-        }
-      }
-    }
-  }
-
-  @Nullable
-  private Address toAddress(Account.Id id) {
-    Optional<Account> accountState = args.accountCache.get(id).map(AccountState::account);
-    if (!accountState.isPresent()) {
-      return null;
-    }
-
-    Account account = accountState.get();
-    String e = account.preferredEmail();
-    if (!account.isActive() || e == null) {
-      return null;
-    }
-    return Address.create(account.fullName(), e);
-  }
-
-  /** Set recipients, headers, body of the email. */
-  public void populateEmailContent() throws EmailException {
-    for (RecipientType recipientType : notify.accounts().keySet()) {
-      notify.accounts().get(recipientType).stream().forEach(a -> addByAccountId(recipientType, a));
-    }
-
-    addSoyParam("messageClass", messageClass);
-    addSoyParam("footers", footers);
-    addSoyEmailDataParam("settingsUrl", getSettingsUrl());
-    addSoyEmailDataParam("instanceName", getInstanceName());
-    addSoyEmailDataParam("gerritHost", getGerritHost());
-    addSoyEmailDataParam("gerritUrl", getGerritUrl());
-    addSoyParam("email", soyContextEmailData);
-
-    templateProvider.populateEmailContent();
-  }
-
-  /** Adds param to the data map passed into soy when rendering templates. */
-  public void addSoyParam(String key, Object value) {
-    soyContext.put(key, value);
-  }
-
-  /** Adds entry to the `email` param passed to the soy when rendering templates. */
-  public void addSoyEmailDataParam(String key, Object value) {
-    soyContextEmailData.put(key, value);
-  }
-
-  /**
-   * Add a line to email footer with additional information. Typically, in the form of {@literal
-   * <key>: <value>}.
-   */
-  public void addFooter(String footer) {
-    footers.add(footer);
-  }
-
-  private String getInstanceName() {
-    return args.instanceNameProvider.get();
-  }
-
-  /** Renders a soy template of kind="text". */
-  public String textTemplate(String name) {
-    return configureRenderer(name).renderText().get();
-  }
-
-  /** Renders a soy template of kind="html". */
-  public String soyHtmlTemplate(String name) {
-    return configureRenderer(name).renderHtml().get().toString();
-  }
-
-  /** Configures a soy renderer for the given template name and rendering data map. */
-  private SoySauce.Renderer configureRenderer(String templateName) {
-    int baseNameIndex = templateName.indexOf("_");
-    // In case there are multiple templates in file (now only InboundEmailRejection and
-    // InboundEmailRejectionHtml).
-    String fileNamespace =
-        baseNameIndex == -1 ? templateName : templateName.substring(0, baseNameIndex);
-    String templateInFileNamespace =
-        String.join(".", SOY_TEMPLATE_NAMESPACE, fileNamespace, templateName);
-    String templateInCommonNamespace = String.join(".", SOY_TEMPLATE_NAMESPACE, templateName);
-    SoySauce soySauce = args.soySauce.get();
-    // For backwards compatibility with existing customizations and plugin templates with the
-    // old non-unique namespace.
-    String fullTemplateName =
-        soySauce.hasTemplate(templateInFileNamespace)
-            ? templateInFileNamespace
-            : templateInCommonNamespace;
-    return soySauce.renderTemplate(fullTemplateName).setData(soyContext);
-  }
-
-  /** Remove user from the multipart email recipients. */
-  private void removeUser(Account user) {
-    String fromEmail = user.preferredEmail();
-    for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) {
-      if (j.next().email().equals(fromEmail)) {
-        j.remove();
-      }
-    }
-    for (Map.Entry<String, EmailHeader> entry : headers.entrySet()) {
-      // Don't remove fromEmail from the "From" header though!
-      if (entry.getValue() instanceof AddressList && !entry.getKey().equals("From")) {
-        ((AddressList) entry.getValue()).remove(fromEmail);
-      }
-    }
-  }
-
-  /** Return true, if the email should include html body. */
-  public boolean useHtml() {
-    return args.settings.html;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecorator.java
new file mode 100644
index 0000000..c82a016
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecorator.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.EmailTokenVerifier;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+
+/**
+ * Sender that informs a user by email about the registration of a new email address for their
+ * account.
+ */
+@AutoFactory
+public class RegisterNewEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+  private final EmailArguments args;
+  private final EmailTokenVerifier tokenVerifier;
+  private final IdentifiedUser user;
+  private final String addr;
+  private String emailToken;
+
+  RegisterNewEmailDecorator(
+      @Provided EmailArguments args,
+      @Provided EmailTokenVerifier tokenVerifier,
+      @Provided IdentifiedUser callingUser,
+      final String address) {
+    this.args = args;
+    this.tokenVerifier = tokenVerifier;
+    this.user = callingUser;
+    this.addr = address;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader("Subject", "[Gerrit Code Review] Email Verification");
+    email.addByEmail(RecipientType.TO, Address.create(addr));
+  }
+
+  public boolean isAllowed() {
+    return args.emailSender.canEmail(addr);
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("emailRegistrationLink", getEmailRegistrationLink());
+
+    email.appendText(email.textTemplate("RegisterNewEmail"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("RegisterNewEmailHtml"));
+    }
+  }
+
+  private String getEmailRegistrationLink() {
+    if (emailToken == null) {
+      emailToken = requireNonNull(tokenVerifier.encode(user.getAccountId(), addr), "token");
+    }
+    return args.urlFormatter.get().getWebUrl().orElse("") + "#/VE/" + emailToken;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
deleted file mode 100644
index 73c5fb5..0000000
--- a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.EmailTokenVerifier;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/**
- * Sender that informs a user by email about the registration of a new email address for their
- * account.
- */
-public class RegisterNewEmailSender extends OutgoingEmail {
-  public interface Factory {
-    RegisterNewEmailSender create(String address);
-  }
-
-  private final EmailTokenVerifier tokenVerifier;
-  private final IdentifiedUser user;
-  private final String addr;
-  private String emailToken;
-
-  @Inject
-  public RegisterNewEmailSender(
-      EmailArguments args,
-      EmailTokenVerifier tokenVerifier,
-      IdentifiedUser callingUser,
-      @Assisted final String address) {
-    super(args, "registernewemail");
-    this.tokenVerifier = tokenVerifier;
-    this.user = callingUser;
-    this.addr = address;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", "[Gerrit Code Review] Email Verification");
-    addByEmail(RecipientType.TO, Address.create(addr));
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("RegisterNewEmail"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("RegisterNewEmailHtml"));
-    }
-  }
-
-  public boolean isAllowed() {
-    return args.emailSender.canEmail(addr);
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-    addSoyEmailDataParam("emailRegistrationToken", getEmailRegistrationToken());
-    addSoyEmailDataParam("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-  }
-
-  private String getEmailRegistrationToken() {
-    if (emailToken == null) {
-      emailToken = requireNonNull(tokenVerifier.encode(user.getAccountId(), addr), "token");
-    }
-    return emailToken;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecorator.java
index a9bd32e..a73933c 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecorator.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.server.mail.send.ChangeEmailNew.ChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 import com.google.gerrit.server.util.LabelVote;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -49,8 +49,8 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final EmailArguments args;
-  private OutgoingEmailNew email;
-  private ChangeEmailNew changeEmail;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
   private final Set<Account.Id> reviewers = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
   private final ChangeKind changeKind;
@@ -106,7 +106,7 @@
   }
 
   @Override
-  public void init(OutgoingEmailNew email, ChangeEmailNew changeEmail) {
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
     this.email = email;
     this.changeEmail = changeEmail;
     changeEmail.markAsReply();
diff --git a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java b/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
deleted file mode 100644
index 1f7b6d5..0000000
--- a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.query.change.ChangeData;
-
-/** Alert a user to a reply to a change, usually commentary made during review. */
-public abstract class ReplyToChangeSender extends ChangeEmail {
-  public interface Factory<T extends ReplyToChangeSender> {
-    T create(Project.NameKey project, Change.Id id);
-  }
-
-  protected ReplyToChangeSender(EmailArguments args, String messageClass, ChangeData changeData) {
-    super(args, messageClass, changeData);
-  }
-
-  @Override
-  protected void populateEmailContent() throws EmailException {
-    super.populateEmailContent();
-
-    final String threadId = getChangeMessageThreadId();
-    setHeader("In-Reply-To", threadId);
-    setHeader("References", threadId);
-
-    addAuthors(RecipientType.TO);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java
index d8c2696..38eab48 100644
--- a/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java
@@ -16,15 +16,15 @@
 
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.mail.send.ChangeEmailNew.ChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 
 /** Send notice about a change being restored by its owner. */
 public class RestoredChangeEmailDecorator implements ChangeEmailDecorator {
-  private OutgoingEmailNew email;
-  private ChangeEmailNew changeEmail;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
 
   @Override
-  public void init(OutgoingEmailNew email, ChangeEmailNew changeEmail) {
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
     this.email = email;
     this.changeEmail = changeEmail;
     changeEmail.markAsReply();
diff --git a/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java
index 2a802f3..d1cff9c 100644
--- a/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java
+++ b/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java
@@ -16,15 +16,15 @@
 
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.mail.send.ChangeEmailNew.ChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 
 /** Send notice about a change being reverted. */
 public class RevertedChangeEmailDecorator implements ChangeEmailDecorator {
-  private OutgoingEmailNew email;
-  private ChangeEmailNew changeEmail;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
 
   @Override
-  public void init(OutgoingEmailNew email, ChangeEmailNew changeEmail) {
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
     this.email = email;
     this.changeEmail = changeEmail;
     changeEmail.markAsReply();
diff --git a/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecorator.java
new file mode 100644
index 0000000..6ae1c6a
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecorator.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Sends an email alerting a user to a new change for them to review. */
+public class StartReviewChangeEmailDecorator implements ChangeEmailDecorator {
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+
+  private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Address> reviewersByEmail = new HashSet<>();
+  private final Set<Account.Id> extraCC = new HashSet<>();
+  private final Set<Address> extraCCByEmail = new HashSet<>();
+  private final Set<Account.Id> removedReviewers = new HashSet<>();
+  private final Set<Address> removedByEmailReviewers = new HashSet<>();
+  private boolean isCreateChange = false;
+
+  public void addReviewers(Collection<Account.Id> cc) {
+    reviewers.addAll(cc);
+  }
+
+  public void addReviewersByEmail(Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
+  public void addExtraCC(Collection<Account.Id> cc) {
+    extraCC.addAll(cc);
+  }
+
+  public void addExtraCCByEmail(Collection<Address> cc) {
+    extraCCByEmail.addAll(cc);
+  }
+
+  public void addRemovedReviewers(Collection<Account.Id> removed) {
+    removedReviewers.addAll(removed);
+  }
+
+  public void addRemovedByEmailReviewers(Collection<Address> removed) {
+    removedByEmailReviewers.addAll(removed);
+  }
+
+  public void markAsCreateChange() {
+    isCreateChange = true;
+  }
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+  }
+
+  @Nullable
+  private List<String> getReviewerNames() {
+    if (reviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : reviewers) {
+      names.add(email.getNameFor(id));
+    }
+    return names;
+  }
+
+  @Nullable
+  private List<String> getRemovedReviewerNames() {
+    if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : removedReviewers) {
+      names.add(email.getNameFor(id));
+    }
+    for (Address address : removedByEmailReviewers) {
+      names.add(address.toString());
+    }
+    return names;
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyParam("ownerName", email.getNameFor(changeEmail.getChange().getOwner()));
+    email.addSoyEmailDataParam("reviewerNames", getReviewerNames());
+    email.addSoyEmailDataParam("removedReviewerNames", getRemovedReviewerNames());
+
+    switch (email.getNotify().handling()) {
+      case NONE:
+      case OWNER:
+        break;
+      case ALL:
+      default:
+        extraCC.stream().forEach(cc -> email.addByAccountId(RecipientType.CC, cc));
+        extraCCByEmail.stream().forEach(cc -> email.addByEmail(RecipientType.CC, cc));
+        // $FALL-THROUGH$
+      case OWNER_REVIEWERS:
+        reviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r, true));
+        reviewersByEmail.stream().forEach(r -> email.addByEmail(RecipientType.TO, r, true));
+        removedReviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r, true));
+        removedByEmailReviewers.stream().forEach(r -> email.addByEmail(RecipientType.TO, r, true));
+        break;
+    }
+    changeEmail.addAuthors(RecipientType.CC);
+
+    if (isCreateChange) {
+      changeEmail.includeWatchers(
+          NotifyType.NEW_CHANGES,
+          !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+      changeEmail.includeWatchers(
+          NotifyType.NEW_PATCHSETS,
+          !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+    } else {
+      changeEmail.ccExistingReviewers();
+    }
+
+    email.appendText(email.textTemplate("NewChange"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("NewChangeHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/AddSshKey.java b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
index f18cc67..2544d3b 100644
--- a/java/com/google/gerrit/server/restapi/account/AddSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
-import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.mail.EmailModule.AddKeyEmailFactories;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -58,7 +58,7 @@
   private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
-  private final AddKeySender.Factory addKeyFactory;
+  private final AddKeyEmailFactories addKeyEmailFactories;
 
   @Inject
   AddSshKey(
@@ -66,12 +66,12 @@
       PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
-      AddKeySender.Factory addKeyFactory) {
+      AddKeyEmailFactories addKeyEmailFactories) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
-    this.addKeyFactory = addKeyFactory;
+    this.addKeyEmailFactories = addKeyEmailFactories;
   }
 
   @Override
@@ -106,7 +106,7 @@
       AccountSshKey sshKey = authorizedKeys.addKey(user.getAccountId(), sshPublicKey);
 
       try {
-        addKeyFactory.create(user, sshKey).send();
+        addKeyEmailFactories.createEmail(user, sshKey).send();
       } catch (EmailException e) {
         logger.atSevere().withCause(e).log(
             "Cannot send SSH key added message to %s", user.getAccount().preferredEmail());
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index 70fbb26..92a7722 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -37,9 +37,11 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.mail.EmailModule.RegisterNewEmailFactories;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.mail.send.RegisterNewEmailDecorator;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -78,7 +80,7 @@
   private final Realm realm;
   private final PermissionBackend permissionBackend;
   private final AccountManager accountManager;
-  private final RegisterNewEmailSender.Factory registerNewEmailFactory;
+  private final RegisterNewEmailFactories registerNewEmailFactories;
   private final PutPreferred putPreferred;
   private final OutgoingEmailValidator validator;
   private final MessageIdGenerator messageIdGenerator;
@@ -92,7 +94,7 @@
       PermissionBackend permissionBackend,
       AuthConfig authConfig,
       AccountManager accountManager,
-      RegisterNewEmailSender.Factory registerNewEmailFactory,
+      RegisterNewEmailFactories registerNewEmailFactories,
       PutPreferred putPreferred,
       OutgoingEmailValidator validator,
       MessageIdGenerator messageIdGenerator,
@@ -101,7 +103,7 @@
     this.realm = realm;
     this.permissionBackend = permissionBackend;
     this.accountManager = accountManager;
-    this.registerNewEmailFactory = registerNewEmailFactory;
+    this.registerNewEmailFactories = registerNewEmailFactories;
     this.putPreferred = putPreferred;
     this.validator = validator;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
@@ -164,12 +166,14 @@
       }
     } else {
       try {
-        RegisterNewEmailSender emailSender = registerNewEmailFactory.create(email);
-        if (!emailSender.isAllowed()) {
+        RegisterNewEmailDecorator emailDecorator =
+            registerNewEmailFactories.createRegisterNewEmail(email);
+        if (!emailDecorator.isAllowed()) {
           throw new MethodNotAllowedException("Not allowed to add email address " + email);
         }
-        emailSender.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-        emailSender.send();
+        OutgoingEmail outgoingEmail = registerNewEmailFactories.createEmail(emailDecorator);
+        outgoingEmail.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+        outgoingEmail.send();
         info.pendingConfirmation = true;
       } catch (EmailException | RuntimeException e) {
         logger.atSevere().withCause(e).log("Cannot send email verification message to %s", email);
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
index e09e48f..61d43d2 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.EmailModule.DeleteKeyEmailFactories;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -51,7 +51,7 @@
   private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final DeleteKeyEmailFactories deleteKeyEmailFactories;
 
   @Inject
   DeleteSshKey(
@@ -59,12 +59,12 @@
       PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
-      DeleteKeySender.Factory deleteKeySenderFactory) {
+      DeleteKeyEmailFactories deleteKeyEmailFactories) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.deleteKeyEmailFactories = deleteKeyEmailFactories;
   }
 
   @Override
@@ -82,7 +82,7 @@
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
     authorizedKeys.deleteKey(user.getAccountId(), sshKey.seq());
     try {
-      deleteKeySenderFactory.create(user, sshKey).send();
+      deleteKeyEmailFactories.createEmail(user, sshKey).send();
     } catch (EmailException e) {
       logger.atSevere().withCause(e).log(
           "Cannot send SSH key deletion message to %s", user.getAccount().preferredEmail());
diff --git a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
index 9361e27..edfc41c 100644
--- a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -37,7 +37,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
+import com.google.gerrit.server.mail.EmailModule.HttpPasswordUpdateEmailFactory;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -78,7 +78,7 @@
   private final PermissionBackend permissionBackend;
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
-  private final HttpPasswordUpdateSender.Factory httpPasswordUpdateSenderFactory;
+  private final HttpPasswordUpdateEmailFactory httpPasswordUpdateEmailFactory;
   private final ExternalIdFactory externalIdFactory;
   private final ExternalIdKeyFactory externalIdKeyFactory;
 
@@ -88,14 +88,14 @@
       PermissionBackend permissionBackend,
       ExternalIds externalIds,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
-      HttpPasswordUpdateSender.Factory httpPasswordUpdateSenderFactory,
+      HttpPasswordUpdateEmailFactory httpPasswordUpdateEmailFactory,
       ExternalIdFactory externalIdFactory,
       ExternalIdKeyFactory externalIdKeyFactory) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
-    this.httpPasswordUpdateSenderFactory = httpPasswordUpdateSenderFactory;
+    this.httpPasswordUpdateEmailFactory = httpPasswordUpdateEmailFactory;
     this.externalIdFactory = externalIdFactory;
     this.externalIdKeyFactory = externalIdKeyFactory;
   }
@@ -146,8 +146,8 @@
                         extId.key(), extId.accountId(), extId.email(), newPassword)));
 
     try {
-      httpPasswordUpdateSenderFactory
-          .create(user, newPassword == null ? "deleted" : "added or updated")
+      httpPasswordUpdateEmailFactory
+          .createEmail(user, newPassword == null ? "deleted" : "added or updated")
           .send();
     } catch (EmailException e) {
       logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
index ef1b6f6..2ff3ab0 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -36,9 +36,9 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.extensions.events.VoteDeleted;
 import com.google.gerrit.server.mail.EmailModule.DeleteVoteChangeEmailFactories;
-import com.google.gerrit.server.mail.send.ChangeEmailNew;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.OutgoingEmailNew;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.permissions.LabelRemovalPermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.DeleteVoteControl;
@@ -188,10 +188,10 @@
 
     CurrentUser user = ctx.getUser();
     try {
-      ChangeEmailNew changeEmail =
+      ChangeEmail changeEmail =
           deleteVoteChangeEmailFactories.createChangeEmail(ctx.getProject(), change.getId());
       changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
-      OutgoingEmailNew outgoingEmail = deleteVoteChangeEmailFactories.createEmail(changeEmail);
+      OutgoingEmail outgoingEmail = deleteVoteChangeEmailFactories.createEmail(changeEmail);
       NotifyResolver.Result notify = ctx.getNotify(change.getId());
       if (user.isIdentifiedUser()) {
         outgoingEmail.setFrom(user.getAccountId());
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index a47e88d..35ea183 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -36,9 +36,9 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.extensions.events.ChangeRestored;
 import com.google.gerrit.server.mail.EmailModule.RestoredChangeEmailFactories;
-import com.google.gerrit.server.mail.send.ChangeEmailNew;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.OutgoingEmailNew;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -152,10 +152,10 @@
     @Override
     public void postUpdate(PostUpdateContext ctx) {
       try {
-        ChangeEmailNew changeEmail =
+        ChangeEmail changeEmail =
             restoredChangeEmailFactories.createChangeEmail(ctx.getProject(), change.getId());
         changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
-        OutgoingEmailNew outgoingEmail = restoredChangeEmailFactories.createEmail(changeEmail);
+        OutgoingEmail outgoingEmail = restoredChangeEmailFactories.createEmail(changeEmail);
         outgoingEmail.setFrom(ctx.getAccountId());
         outgoingEmail.setMessageId(
             messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index 1cac440..a823013 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -24,9 +24,9 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.EmailModule.MergedChangeEmailFactories;
-import com.google.gerrit.server.mail.send.ChangeEmailNew;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.OutgoingEmailNew;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -95,12 +95,12 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      ChangeEmailNew changeEmail =
+      ChangeEmail changeEmail =
           mergedChangeEmailFactories.createChangeEmail(
               project,
               change.getId(),
               Optional.ofNullable(Strings.emptyToNull(stickyApprovalDiff)));
-      OutgoingEmailNew outgoingEmail = mergedChangeEmailFactories.createEmail(changeEmail);
+      OutgoingEmail outgoingEmail = mergedChangeEmailFactories.createEmail(changeEmail);
       if (submitter != null) {
         outgoingEmail.setFrom(submitter.getAccountId());
       }
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 9250513..14d781d 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -19,6 +19,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
 import static com.google.common.flogger.LazyArgs.lazy;
+import static com.google.gerrit.common.UsedAt.Project.GOOGLE;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toMap;
@@ -39,6 +40,7 @@
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -52,6 +54,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.RefLogIdentityProvider;
@@ -666,9 +669,14 @@
     }
   }
 
+  // For upstream implementation, AccessPath.WEB_BROWSER is never set, so the method will always
+  // return false.
+  @UsedAt(GOOGLE)
   private boolean indexAsync() {
-    return experimentFeatures.isFeatureEnabled(
-        ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING);
+    return user.getAccessPath().equals(AccessPath.WEB_BROWSER)
+        && experimentFeatures.isFeatureEnabled(
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING,
+            project);
   }
 
   private void fireRefChangeEvent() {
@@ -751,6 +759,8 @@
         }
       }
       if (indexAsync) {
+        logger.atFine().log(
+            "Asynchronously reindexing changes, %s in project %s", results.keySet(), project.get());
         // We want to index asynchronously. However, the callers will await all
         // index futures. This allows us to - even in synchronous case -
         // parallelize indexing changes.
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index c00e810..102b052 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -24,10 +24,10 @@
 import com.google.gerrit.server.mail.EmailModule.AttentionSetChangeEmailFactories;
 import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange;
-import com.google.gerrit.server.mail.send.ChangeEmailNew;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
-import com.google.gerrit.server.mail.send.OutgoingEmailNew;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.update.Context;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -154,10 +154,10 @@
         changeEmailParams.setAttentionSetChange(attentionSetChange);
         changeEmailParams.setAttentionSetUser(attentionUserId);
         changeEmailParams.setReason(reason);
-        ChangeEmailNew changeEmail =
+        ChangeEmail changeEmail =
             attentionSetChangeEmailFactories.createChangeEmail(
                 projectId, changeId, changeEmailParams);
-        OutgoingEmailNew outgoingEmail =
+        OutgoingEmail outgoingEmail =
             attentionSetChangeEmailFactories.createEmail(attentionSetChange, changeEmail);
 
         Optional<Account.Id> accountId =
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 15baa78..abaf98f 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1262,6 +1262,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(0).id
+                + "?usp=email"
                 + " :\n"
                 + "PS1, Line 1: initial\n"
                 + "what happened to this?\n"
@@ -1274,6 +1275,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(1).id
+                + "?usp=email"
                 + " :\n"
                 + "PS1, Line 1: boring\n"
                 + "Is it that bad?\n"
@@ -1288,6 +1290,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(0).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 1: initial content\n"
                 + "comment 1 on base\n"
@@ -1300,6 +1303,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(1).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 2: \n"
                 + "comment 2 on base\n"
@@ -1312,6 +1316,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(2).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 1: interesting\n"
                 + "better now\n"
@@ -1324,6 +1329,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(3).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 2: cntent\n"
                 + "typo: content\n"
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index f728995..5e00230 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -62,7 +62,7 @@
     Map<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
     String hostname = URI.create(canonicalWebUrl.get()).getHost();
     String listId = String.format("<gerrit-%s.%s>", project.get(), hostname);
-    String unsubscribeLink = String.format("<%ssettings>", canonicalWebUrl.get());
+    String unsubscribeLink = String.format("<%ssettings?usp=email>", canonicalWebUrl.get());
     String threadId =
         String.format(
             "<gerrit.%s.%s@%s>",
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index a188251..688e5e4 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -83,6 +83,7 @@
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -2886,7 +2887,8 @@
   }
 
   @Test
-  public void byStar() throws Exception {
+  public void byStar_withStarOptionSet() throws Exception {
+    // When star option is set, the 'starred' field is set in the change infos in response.
     repo = createAndOpenProject("repo");
     Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
@@ -2899,6 +2901,32 @@
     // check default star
     assertQuery("has:star", change1);
     assertQuery("is:starred", change1);
+
+    // The 'Star' bit in the change data is also set correctly
+    List<ChangeInfo> changeInfos =
+        gApi.changes().query("has:star").withOptions(ListChangesOption.STAR).get();
+    assertThat(changeInfos.get(0).starred).isTrue();
+  }
+
+  @Test
+  public void byStar_withStarOptionNotSet() throws Exception {
+    // When star option is not set, the 'starred' field is not set in the change infos in response.
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    requestContext.setContext(newRequestContext(user2));
+
+    gApi.accounts().self().starChange(change1.getId().toString());
+
+    // check default star
+    assertQuery("has:star", change1);
+    assertQuery("is:starred", change1);
+
+    // The 'Star' bit in the change data is not set if the backfilling option is not set
+    List<ChangeInfo> changeInfos = gApi.changes().query("has:star").get();
+    assertThat(changeInfos.get(0).starred).isNull();
   }
 
   @Test
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index 4d0d3f1..e15c240 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -39,8 +39,6 @@
 // The name that gets automatically input when a new reference is added.
 const NEW_NAME = 'refs/heads/*';
 const REFS_NAME = 'refs/';
-const ON_BEHALF_OF = '(On Behalf Of)';
-const LABEL = 'Label';
 
 @customElement('gr-access-section')
 export class GrAccessSection extends LitElement {
@@ -360,14 +358,14 @@
       labelOptions.push({
         id: 'label-' + labelName,
         value: {
-          name: `${LABEL} ${labelName}`,
+          name: `Label ${labelName}`,
           id: 'label-' + labelName,
         },
       });
       labelOptions.push({
         id: 'labelAs-' + labelName,
         value: {
-          name: `${LABEL} ${labelName} ${ON_BEHALF_OF}`,
+          name: `Label ${labelName} (On Behalf Of)`,
           id: 'labelAs-' + labelName,
         },
       });
@@ -384,11 +382,13 @@
     } else if (AccessPermissions[permission.id]) {
       return AccessPermissions[permission.id]?.name;
     } else if (permission.value.label) {
-      let behalfOf = '';
       if (permission.id.startsWith('labelAs-')) {
-        behalfOf = ON_BEHALF_OF;
+        return `Label ${permission.value.label} (On Behalf Of)`;
+      } else if (permission.id.startsWith('removeLabel-')) {
+        return `Remove Label ${permission.value.label}`;
+      } else {
+        return `Label ${permission.value.label}`;
       }
-      return `${LABEL} ${permission.value.label}${behalfOf}`;
     }
     return undefined;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
index 593a1ed..2c397e0 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
@@ -355,7 +355,20 @@
 
       assert.equal(
         element.computePermissionName(permission),
-        'Label Code-Review(On Behalf Of)'
+        'Label Code-Review (On Behalf Of)'
+      );
+
+      permission = {
+        id: 'removeLabel-Code-Review' as GitRef,
+        value: {
+          label: 'Code-Review',
+          rules: {},
+        },
+      };
+
+      assert.equal(
+        element.computePermissionName(permission),
+        'Remove Label Code-Review'
       );
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 2e1a8b9..02fdb34 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -1706,7 +1706,6 @@
 
     const options = {
       mergeable: this.mergeable,
-      submitEnabled: !!this.isSubmitEnabled(),
       revertingChangeStatus: this.revertingChange?.status,
     };
     return changeStatuses(this.change as ChangeInfo, options);
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 4490afa..7de5e7e 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -16,10 +16,8 @@
 import {ParsedChangeInfo} from '../types/types';
 import {getUserId, isServiceUser} from './account-util';
 
-// This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
-  mergeable: boolean; // This can be wrong! See WARNING above
-  submitEnabled: boolean; // This can be wrong! See WARNING above
+  mergeable: boolean;
   /** Is there a reverting change and if so, what status has it? */
   revertingChangeStatus?: ChangeStatus;
 }
@@ -190,11 +188,9 @@
     return states;
   }
 
-  // If no missing requirements, either active or ready to submit.
-  if (change.submittable && options.submitEnabled) {
+  if (change.submittable) {
     states.push(ChangeStates.READY_TO_SUBMIT);
   } else {
-    // Otherwise it is active.
     states.push(ChangeStates.ACTIVE);
   }
   return states;
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
index f768145..1782d80 100644
--- a/polygerrit-ui/app/utils/change-util_test.ts
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -66,29 +66,24 @@
     assert.deepEqual(statuses, []);
 
     change.submittable = false;
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
+    statuses = changeStatuses(change, {mergeable: true});
     assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
 
-    // With no missing labels but no submitEnabled option.
     change.submittable = true;
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
-    assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
-
-    // Without missing labels and enabled submit
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    statuses = changeStatuses(change, {mergeable: true});
     assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
 
     change.mergeable = false;
     change.submittable = true;
-    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    statuses = changeStatuses(change, {mergeable: false});
     assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
 
     change.mergeable = true;
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    statuses = changeStatuses(change, {mergeable: true});
     assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
 
     change.submittable = true;
-    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    statuses = changeStatuses(change, {mergeable: false});
     assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
   });
 
@@ -141,7 +136,6 @@
       changeStatuses(change, {
         revertingChangeStatus: ChangeStatus.NEW,
         mergeable: true,
-        submitEnabled: true,
       }),
       [ChangeStates.MERGED, ChangeStates.REVERT_CREATED]
     );
@@ -149,7 +143,6 @@
       changeStatuses(change, {
         revertingChangeStatus: ChangeStatus.MERGED,
         mergeable: true,
-        submitEnabled: true,
       }),
       [ChangeStates.MERGED, ChangeStates.REVERT_SUBMITTED]
     );
diff --git a/resources/com/google/gerrit/server/mail/AddKey.soy b/resources/com/google/gerrit/server/mail/AddKey.soy
index 319db05..8958ea3 100644
--- a/resources/com/google/gerrit/server/mail/AddKey.soy
+++ b/resources/com/google/gerrit/server/mail/AddKey.soy
@@ -48,9 +48,9 @@
   You can also manage your {$email.keyType} keys by visiting
   {\n}
   {if $email.sshKey}
-    {$email.gerritUrl}#/settings/ssh-keys
+    {$email.sshKeysSettingsUrl}
   {elseif $email.gpgKeys}
-    {$email.gerritUrl}#/settings/gpg-keys
+    {$email.gpgKeysSettingsUrl}
   {/if}
   {\n}
   {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
index c356a95..cb5b224 100644
--- a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -47,9 +47,9 @@
   <p>
     You can also manage your {$email.keyType} keys by following{sp}
     {if $email.sshKey}
-      <a href="{$email.gerritUrl}#/settings/ssh-keys">this link</a>
+      <a href="{$email.sshKeysSettingsUrl}">this link</a>
     {elseif $email.gpgKeys}
-      <a href="{$email.gerritUrl}#/settings/gpg-keys">this link</a>
+      <a href="{$email.gpgKeysSettingsUrl}">this link</a>
     {/if}
     {sp}
     {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/DeleteKey.soy b/resources/com/google/gerrit/server/mail/DeleteKey.soy
index 0957dc6..46bfc7e 100644
--- a/resources/com/google/gerrit/server/mail/DeleteKey.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteKey.soy
@@ -47,9 +47,9 @@
   You can also manage your {$email.keyType} keys by visiting
   {\n}
   {if $email.sshKey}
-    {$email.gerritUrl}#/settings/ssh-keys
+    {$email.sshKeysSettingsUrl}
   {elseif $email.gpgKey}
-    {$email.gerritUrl}#/settings/gpg-keys
+    {$email.gpgKeysSettingsUrl}
   {/if}
   {\n}
   {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
index fea6785..539688e 100644
--- a/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
@@ -45,9 +45,9 @@
   <p>
     You can also manage your {$email.keyType} keys by following{sp}
     {if $email.sshKey}
-      <a href="{$email.gerritUrl}#/settings/ssh-keys">this link</a>
+      <a href="{$email.sshKeysSettingsUrl}">this link</a>
     {elseif $email.gpgKeyFingerprints}
-      <a href="{$email.gerritUrl}#/settings/gpg-keys">this link</a>
+      <a href="{$email.gpgKeysSettingsUrl}">this link</a>
     {/if}
     {sp}
     {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/Email.soy b/resources/com/google/gerrit/server/mail/Email.soy
new file mode 100644
index 0000000..9afea72
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/Email.soy
@@ -0,0 +1,27 @@
+/**
+ * Copyright (C) 2023 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.
+*/
+
+{namespace com.google.gerrit.server.mail.template.Email}
+
+/**
+ * The .Email template defines the structure of the content in the email.
+ */
+{template Email kind="text"}
+  {@param body: string}
+  {@param footer: string}
+  {$body}
+  {$footer}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/EmailHtml.soy b/resources/com/google/gerrit/server/mail/EmailHtml.soy
new file mode 100644
index 0000000..5b5ea63
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/EmailHtml.soy
@@ -0,0 +1,38 @@
+/**
+ * Copyright (C) 2023 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.
+*/
+
+{namespace com.google.gerrit.server.mail.template.EmailHtml}
+
+/**
+ * The .EmailHtml template defines the structure of the content in the email.
+ */
+{template EmailHtml}
+  {@param styles: css}
+  {@param body: html}
+  {@param footer: html}
+  <!DOCTYPE html>
+  <html>
+    <head>
+      <style>
+        {$styles}
+      </style>
+    </head>
+    <body>
+      {$body}
+      {$footer}
+    </body>
+  </html>
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
index 49fbccb..3efa8be 100644
--- a/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
@@ -33,7 +33,7 @@
 
   You can also manage your HTTP password by visiting
   {\n}
-  {$email.gerritUrl}#/settings/http-password
+  {$email.httpPasswordSettingsUrl}
   {\n}
   {if $email.userNameEmail}
     (while signed in as {$email.userNameEmail})
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
index 3f88a6f..ee033cd 100644
--- a/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
@@ -30,7 +30,7 @@
 
   <p>
     You can also manage your HTTP password by following{sp}
-    <a href="{$email.gerritUrl}#/settings/http-password">this link</a>
+    <a href="{$email.httpPasswordSettingsUrl}">this link</a>
     {sp}
     {if $email.userNameEmail}
       (while signed in as {$email.userNameEmail})
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
index 273f52f..cd38742 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
@@ -34,7 +34,7 @@
 
   {\n}
 
-  {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}{\n}
+  {$email.emailRegistrationLink}{\n}
 
   {\n}
 
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
index 7d6cd23..20f9999 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
@@ -31,7 +31,7 @@
 
   <p>
 
-    {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}
+    {$email.emailRegistrationLink}
   </p>
   <p>
     If you have received this mail in error, you do not need to take any