All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.gerrit.server.mail.send.ChangeEmailImpl Maven / Gradle / Ivy

There is a newer version: 3.10.1
Show newest version
// 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.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.entities.Project;
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.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.text.MessageFormat;
import java.time.Instant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
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.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;

/** Populates an email for change related notifications. */
@AutoFactory
public class ChangeEmailImpl implements ChangeEmail {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();

  // Available after construction
  protected final EmailArguments args;
  protected final Set currentAttentionSet;
  protected final Change change;
  protected final ChangeData changeData;
  protected final BranchNameKey branch;
  protected final ChangeEmailDecorator changeEmailDecorator;

  // Available after init or after being explicitly set.
  protected OutgoingEmail email;
  private List stars;
  protected PatchSet patchSet;
  protected PatchSetInfo patchSetInfo;
  private String changeMessage;
  private String changeMessageThreadId;
  private Instant timestamp;
  private ProjectState projectState;
  private Set authors;
  private boolean emailOnlyAuthors;
  private boolean emailOnlyAttentionSetIfEnabled;
  // Watchers ignore attention set rules.
  private Set watcherAccounts = new HashSet<>();
  // Watcher can only be an email if it's specified in notify section of ProjectConfig.
  private Set
watcherEmails = new HashSet<>(); private boolean isThreadReply = false; public ChangeEmailImpl( @Provided EmailArguments args, Project.NameKey project, Change.Id changeId, ChangeEmailDecorator changeEmailDecorator) { this.args = args; this.changeData = args.newChangeData(project, changeId); change = changeData.change(); emailOnlyAuthors = false; emailOnlyAttentionSetIfEnabled = true; currentAttentionSet = getAttentionSet(); branch = changeData.change().getDest(); this.changeEmailDecorator = changeEmailDecorator; } @Override public void markAsReply() { isThreadReply = true; } @Override public Change getChange() { return change; } @Override public ChangeData getChangeData() { return changeData; } @Override @Nullable public Instant getTimestamp() { return timestamp; } @Override public void setPatchSet(PatchSet ps) { patchSet = ps; } @Override @Nullable public PatchSet getPatchSet() { return patchSet; } @Override public void setPatchSet(PatchSet ps, PatchSetInfo psi) { patchSet = ps; patchSetInfo = psi; } @Override public void setChangeMessage(String cm, Instant t) { changeMessage = cm; timestamp = t; } @Override public void setEmailOnlyAttentionSetIfEnabled(boolean value) { emailOnlyAttentionSetIfEnabled = value; } @Override public boolean shouldSendMessage() { return changeEmailDecorator.shouldSendMessage(); } @Override public void init(OutgoingEmail email) throws EmailException { this.email = email; changeMessageThreadId = String.format( "", 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()); } } protected void setChangeSubjectHeader() { email.setHeader(FieldName.SUBJECT, email.textTemplate("ChangeSubject")); } @Override public 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); } @Override public 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 protected String getChangeUrl() { return args.urlFormatter .get() .getChangeViewUrl(change.getProject(), change.getId()) .map(EmailArguments::addUspParam) .orElse(null); } /** Sets headers for conversation grouping */ protected void setThreadHeaders() { if (isThreadReply) { email.setHeader("In-Reply-To", changeMessageThreadId); } email.setHeader("References", changeMessageThreadId); } /** Get the text of the "cover letter". */ @Override public String getCoverLetter() { if (changeMessage != null) { return changeMessage.trim(); } return ""; } /** Create the change message and the affected file list. */ protected 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 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. */ @Override public Map 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. */ @Override public Map 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. */ @Override public ProjectState getProjectState() { return projectState; } /** TO or CC all vested parties (change owner, patch set uploader, author). */ @Override public void addAuthors(RecipientType rt) { for (Account.Id id : getAuthors()) { email.addByAccountId(rt, id); } } /** BCC any user who has starred this change. */ @Override public void bccStarredBy() { if (!NotifyHandling.ALL.equals(email.getNotify().handling())) { return; } stars.forEach(accountId -> email.addByAccountId(RecipientType.BCC, accountId)); } /** Include users and groups that want notification of events. */ @Override public void includeWatchers(NotifyType type) { includeWatchers(type, true); } /** Include users and groups that want notification of events. */ @Override 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. */ @Override 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. */ @Override 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 = 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 getAuthors() { if (this.authors != null) { return this.authors; } Set 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", ChangeEmail.getDiffTemplateData(getUnifiedDiff())); email.addSoyEmailDataParam("unifiedDiff", getUnifiedDiff()); email.addSoyEmailDataParam("changeDetail", getChangeDetail()); email.addSoyEmailDataParam("changeUrl", getChangeUrl()); email.addSoyEmailDataParam("includeDiff", getIncludeDiff()); Map 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 patchSetData = new HashMap<>(); patchSetData.put("patchSetId", patchSet.number()); patchSetData.put("refName", patchSet.refName()); email.addSoyParam("patchSet", patchSetData); Map 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)); } // 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. */ protected static String shortenSubject(String subject) { if (subject.length() < 73) { return subject; } return subject.substring(0, 69) + "..."; } protected Set getEmailsByState(ReviewerStateInternal state) { Set 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 getAttentionSet() { Set 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; } protected boolean getIncludeDiff() { return args.settings.includeDiff; } private static final int HEAP_EST_SIZE = 32 * 1024; /** Show patch set as unified difference. */ @Override public String getUnifiedDiff() { Map 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 ""; } } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy