LCOV - code coverage report
Current view: top level - server/mail/receive - MailProcessor.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 190 208 91.3 %
Date: 2022-11-19 15:00:39 Functions: 20 21 95.2 %

          Line data    Source code
       1             : // Copyright (C) 2016 The Android Open Source Project
       2             : //
       3             : // Licensed under the Apache License, Version 2.0 (the "License");
       4             : // you may not use this file except in compliance with the License.
       5             : // You may obtain a copy of the License at
       6             : //
       7             : // http://www.apache.org/licenses/LICENSE-2.0
       8             : //
       9             : // Unless required by applicable law or agreed to in writing, software
      10             : // distributed under the License is distributed on an "AS IS" BASIS,
      11             : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12             : // See the License for the specific language governing permissions and
      13             : // limitations under the License.
      14             : 
      15             : package com.google.gerrit.server.mail.receive;
      16             : 
      17             : import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
      18             : import static java.util.stream.Collectors.toList;
      19             : 
      20             : import com.google.common.base.Strings;
      21             : import com.google.common.collect.ImmutableList;
      22             : import com.google.common.collect.ImmutableMap;
      23             : import com.google.common.collect.Iterables;
      24             : import com.google.common.flogger.FluentLogger;
      25             : import com.google.gerrit.entities.Account;
      26             : import com.google.gerrit.entities.Change;
      27             : import com.google.gerrit.entities.HumanComment;
      28             : import com.google.gerrit.entities.PatchSet;
      29             : import com.google.gerrit.entities.Project;
      30             : import com.google.gerrit.exceptions.StorageException;
      31             : import com.google.gerrit.extensions.client.Side;
      32             : import com.google.gerrit.extensions.registration.DynamicItem;
      33             : import com.google.gerrit.extensions.registration.DynamicMap;
      34             : import com.google.gerrit.extensions.registration.Extension;
      35             : import com.google.gerrit.extensions.restapi.RestApiException;
      36             : import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
      37             : import com.google.gerrit.extensions.validators.CommentForValidation;
      38             : import com.google.gerrit.extensions.validators.CommentValidationContext;
      39             : import com.google.gerrit.extensions.validators.CommentValidationFailure;
      40             : import com.google.gerrit.extensions.validators.CommentValidator;
      41             : import com.google.gerrit.mail.HtmlParser;
      42             : import com.google.gerrit.mail.MailComment;
      43             : import com.google.gerrit.mail.MailHeaderParser;
      44             : import com.google.gerrit.mail.MailMessage;
      45             : import com.google.gerrit.mail.MailMetadata;
      46             : import com.google.gerrit.mail.TextParser;
      47             : import com.google.gerrit.server.ChangeMessagesUtil;
      48             : import com.google.gerrit.server.CommentsUtil;
      49             : import com.google.gerrit.server.PatchSetUtil;
      50             : import com.google.gerrit.server.PublishCommentUtil;
      51             : import com.google.gerrit.server.account.AccountCache;
      52             : import com.google.gerrit.server.account.AccountState;
      53             : import com.google.gerrit.server.account.Emails;
      54             : import com.google.gerrit.server.approval.ApprovalsUtil;
      55             : import com.google.gerrit.server.change.EmailReviewComments;
      56             : import com.google.gerrit.server.config.UrlFormatter;
      57             : import com.google.gerrit.server.extensions.events.CommentAdded;
      58             : import com.google.gerrit.server.mail.MailFilter;
      59             : import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
      60             : import com.google.gerrit.server.mail.send.InboundEmailRejectionSender.InboundEmailError;
      61             : import com.google.gerrit.server.mail.send.MessageIdGenerator;
      62             : import com.google.gerrit.server.notedb.ChangeNotes;
      63             : import com.google.gerrit.server.plugincontext.PluginSetContext;
      64             : import com.google.gerrit.server.query.change.ChangeData;
      65             : import com.google.gerrit.server.query.change.InternalChangeQuery;
      66             : import com.google.gerrit.server.update.BatchUpdate;
      67             : import com.google.gerrit.server.update.BatchUpdateOp;
      68             : import com.google.gerrit.server.update.ChangeContext;
      69             : import com.google.gerrit.server.update.PostUpdateContext;
      70             : import com.google.gerrit.server.update.RetryHelper;
      71             : import com.google.gerrit.server.update.UpdateException;
      72             : import com.google.gerrit.server.util.ManualRequestContext;
      73             : import com.google.gerrit.server.util.OneOffRequestContext;
      74             : import com.google.gerrit.server.util.time.TimeUtil;
      75             : import com.google.inject.Inject;
      76             : import com.google.inject.Provider;
      77             : import com.google.inject.Singleton;
      78             : import java.io.IOException;
      79             : import java.util.ArrayList;
      80             : import java.util.Collection;
      81             : import java.util.HashMap;
      82             : import java.util.HashSet;
      83             : import java.util.List;
      84             : import java.util.Map;
      85             : import java.util.Optional;
      86             : import java.util.Set;
      87             : 
      88             : /** A service that can attach the comments from a {@link MailMessage} to a change. */
      89             : @Singleton
      90             : public class MailProcessor {
      91           4 :   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
      92             : 
      93             :   private static final ImmutableMap<MailComment.CommentType, CommentForValidation.CommentType>
      94           4 :       MAIL_COMMENT_TYPE_TO_VALIDATION_TYPE =
      95           4 :           ImmutableMap.of(
      96             :               MailComment.CommentType.PATCHSET_LEVEL,
      97             :                   CommentForValidation.CommentType.CHANGE_MESSAGE,
      98             :               MailComment.CommentType.FILE_COMMENT, CommentForValidation.CommentType.FILE_COMMENT,
      99             :               MailComment.CommentType.INLINE_COMMENT,
     100             :                   CommentForValidation.CommentType.INLINE_COMMENT);
     101             : 
     102             :   private final Emails emails;
     103             :   private final InboundEmailRejectionSender.Factory emailRejectionSender;
     104             :   private final RetryHelper retryHelper;
     105             :   private final ChangeMessagesUtil changeMessagesUtil;
     106             :   private final CommentsUtil commentsUtil;
     107             :   private final OneOffRequestContext oneOffRequestContext;
     108             :   private final PatchSetUtil psUtil;
     109             :   private final Provider<InternalChangeQuery> queryProvider;
     110             :   private final DynamicMap<MailFilter> mailFilters;
     111             :   private final EmailReviewComments.Factory outgoingMailFactory;
     112             :   private final CommentAdded commentAdded;
     113             :   private final ApprovalsUtil approvalsUtil;
     114             :   private final AccountCache accountCache;
     115             :   private final DynamicItem<UrlFormatter> urlFormatter;
     116             :   private final PluginSetContext<CommentValidator> commentValidators;
     117             :   private final MessageIdGenerator messageIdGenerator;
     118             : 
     119             :   @Inject
     120             :   public MailProcessor(
     121             :       Emails emails,
     122             :       InboundEmailRejectionSender.Factory emailRejectionSender,
     123             :       RetryHelper retryHelper,
     124             :       ChangeMessagesUtil changeMessagesUtil,
     125             :       CommentsUtil commentsUtil,
     126             :       OneOffRequestContext oneOffRequestContext,
     127             :       PatchSetUtil psUtil,
     128             :       Provider<InternalChangeQuery> queryProvider,
     129             :       DynamicMap<MailFilter> mailFilters,
     130             :       EmailReviewComments.Factory outgoingMailFactory,
     131             :       ApprovalsUtil approvalsUtil,
     132             :       CommentAdded commentAdded,
     133             :       AccountCache accountCache,
     134             :       DynamicItem<UrlFormatter> urlFormatter,
     135             :       PluginSetContext<CommentValidator> commentValidators,
     136           4 :       MessageIdGenerator messageIdGenerator) {
     137           4 :     this.emails = emails;
     138           4 :     this.emailRejectionSender = emailRejectionSender;
     139           4 :     this.retryHelper = retryHelper;
     140           4 :     this.changeMessagesUtil = changeMessagesUtil;
     141           4 :     this.commentsUtil = commentsUtil;
     142           4 :     this.oneOffRequestContext = oneOffRequestContext;
     143           4 :     this.psUtil = psUtil;
     144           4 :     this.queryProvider = queryProvider;
     145           4 :     this.mailFilters = mailFilters;
     146           4 :     this.outgoingMailFactory = outgoingMailFactory;
     147           4 :     this.commentAdded = commentAdded;
     148           4 :     this.approvalsUtil = approvalsUtil;
     149           4 :     this.accountCache = accountCache;
     150           4 :     this.urlFormatter = urlFormatter;
     151           4 :     this.commentValidators = commentValidators;
     152           4 :     this.messageIdGenerator = messageIdGenerator;
     153           4 :   }
     154             : 
     155             :   /**
     156             :    * Parses comments from a {@link MailMessage} and persists them on the change.
     157             :    *
     158             :    * @param message {@link MailMessage} to process
     159             :    */
     160             :   public void process(MailMessage message) throws RestApiException, UpdateException {
     161           3 :     retryHelper
     162           3 :         .changeUpdate(
     163             :             "processCommentsReceivedByEmail",
     164             :             buf -> {
     165           3 :               processImpl(buf, message);
     166           3 :               return null;
     167             :             })
     168           3 :         .call();
     169           3 :   }
     170             : 
     171             :   private void processImpl(BatchUpdate.Factory buf, MailMessage message)
     172             :       throws UpdateException, RestApiException, IOException {
     173           3 :     for (Extension<MailFilter> filter : mailFilters) {
     174           3 :       if (!filter.getProvider().get().shouldProcessMessage(message)) {
     175           1 :         logger.atWarning().log(
     176             :             "Message %s filtered by plugin %s %s. Will delete message.",
     177           1 :             message.id(), filter.getPluginName(), filter.getExportName());
     178           1 :         return;
     179             :       }
     180           3 :     }
     181             : 
     182           3 :     MailMetadata metadata = MailHeaderParser.parse(message);
     183             : 
     184           3 :     if (!metadata.hasRequiredFields()) {
     185           2 :       logger.atSevere().log(
     186             :           "Message %s is missing required metadata, have %s. Will delete message.",
     187           2 :           message.id(), metadata);
     188           2 :       sendRejectionEmail(message, InboundEmailError.PARSING_ERROR);
     189           2 :       return;
     190             :     }
     191             : 
     192           2 :     Set<Account.Id> accountIds = emails.getAccountFor(metadata.author);
     193             : 
     194           2 :     if (accountIds.size() != 1) {
     195           0 :       logger.atSevere().log(
     196             :           "Address %s could not be matched to a unique account. It was matched to %s."
     197             :               + " Will delete message.",
     198             :           metadata.author, accountIds);
     199             : 
     200             :       // We don't want to send an email if no accounts are linked to it.
     201           0 :       if (accountIds.size() > 1) {
     202           0 :         sendRejectionEmail(message, InboundEmailError.UNKNOWN_ACCOUNT);
     203             :       }
     204           0 :       return;
     205             :     }
     206           2 :     Account.Id accountId = accountIds.iterator().next();
     207           2 :     Optional<AccountState> accountState = accountCache.get(accountId);
     208           2 :     if (!accountState.isPresent()) {
     209           0 :       logger.atWarning().log("Mail: Account %s doesn't exist. Will delete message.", accountId);
     210           0 :       return;
     211             :     }
     212           2 :     if (!accountState.get().account().isActive()) {
     213           1 :       logger.atWarning().log("Mail: Account %s is inactive. Will delete message.", accountId);
     214           1 :       sendRejectionEmail(message, InboundEmailError.INACTIVE_ACCOUNT);
     215           1 :       return;
     216             :     }
     217             : 
     218           2 :     persistComments(buf, message, metadata, accountId);
     219           2 :   }
     220             : 
     221             :   private void sendRejectionEmail(MailMessage message, InboundEmailError reason) {
     222             :     try {
     223           2 :       InboundEmailRejectionSender emailSender =
     224           2 :           emailRejectionSender.create(message.from(), message.id(), reason);
     225           2 :       emailSender.setMessageId(messageIdGenerator.fromMailMessage(message));
     226           2 :       emailSender.send();
     227           0 :     } catch (Exception e) {
     228           0 :       logger.atSevere().withCause(e).log("Cannot send email to warn for an error");
     229           2 :     }
     230           2 :   }
     231             : 
     232             :   private void persistComments(
     233             :       BatchUpdate.Factory buf, MailMessage message, MailMetadata metadata, Account.Id sender)
     234             :       throws UpdateException, RestApiException {
     235           2 :     try (ManualRequestContext ctx = oneOffRequestContext.openAs(sender)) {
     236           2 :       List<ChangeData> changeDataList =
     237             :           queryProvider
     238           2 :               .get()
     239           2 :               .enforceVisibility(true)
     240           2 :               .byLegacyChangeId(Change.id(metadata.changeNumber));
     241           2 :       if (changeDataList.isEmpty()) {
     242           1 :         sendRejectionEmail(message, InboundEmailError.CHANGE_NOT_FOUND);
     243           1 :         return;
     244             :       }
     245           2 :       if (changeDataList.size() != 1) {
     246           0 :         logger.atSevere().log(
     247             :             "Message %s references unique change %s,"
     248             :                 + " but there are %d matching changes in the index."
     249             :                 + " Will delete message.",
     250           0 :             message.id(), metadata.changeNumber, changeDataList.size());
     251             : 
     252           0 :         sendRejectionEmail(message, InboundEmailError.INTERNAL_EXCEPTION);
     253           0 :         return;
     254             :       }
     255           2 :       ChangeData cd = Iterables.getOnlyElement(changeDataList);
     256           2 :       if (existingMessageIds(cd).contains(message.id())) {
     257           1 :         logger.atInfo().log("Message %s was already processed. Will delete message.", message.id());
     258           1 :         return;
     259             :       }
     260             :       // Get all comments; filter and sort them to get the original list of
     261             :       // comments from the outbound email.
     262             :       // TODO(hiesel) Also filter by original comment author.
     263           2 :       Collection<HumanComment> comments =
     264           2 :           cd.publishedComments().stream()
     265           2 :               .filter(c -> (c.writtenOn.getTime() / 1000) == (metadata.timestamp.getTime() / 1000))
     266           2 :               .sorted(CommentsUtil.COMMENT_ORDER)
     267           2 :               .collect(toList());
     268           2 :       Project.NameKey project = cd.project();
     269             : 
     270             :       // If URL is not defined, we won't be able to parse line comments. We still attempt to get the
     271             :       // other ones.
     272           2 :       String changeUrl =
     273             :           urlFormatter
     274           2 :               .get()
     275           2 :               .getChangeViewUrl(cd.project(), cd.getId())
     276           2 :               .orElse("http://gerrit.invalid/");
     277             : 
     278             :       List<MailComment> parsedComments;
     279           2 :       if (useHtmlParser(message)) {
     280           0 :         parsedComments = HtmlParser.parse(message, comments, changeUrl);
     281             :       } else {
     282           2 :         parsedComments = TextParser.parse(message, comments, changeUrl);
     283             :       }
     284             : 
     285           2 :       if (parsedComments.isEmpty()) {
     286           0 :         logger.atWarning().log(
     287           0 :             "Could not parse any comments from %s. Will delete message.", message.id());
     288           0 :         sendRejectionEmail(message, InboundEmailError.PARSING_ERROR);
     289           0 :         return;
     290             :       }
     291             : 
     292           2 :       ImmutableList<CommentForValidation> parsedCommentsForValidation =
     293           2 :           parsedComments.stream()
     294           2 :               .map(
     295             :                   comment ->
     296           2 :                       CommentForValidation.create(
     297             :                           CommentForValidation.CommentSource.HUMAN,
     298           2 :                           MAIL_COMMENT_TYPE_TO_VALIDATION_TYPE.get(comment.getType()),
     299           2 :                           comment.getMessage(),
     300           2 :                           comment.getMessage().length()))
     301           2 :               .collect(ImmutableList.toImmutableList());
     302           2 :       CommentValidationContext commentValidationCtx =
     303           2 :           CommentValidationContext.create(
     304           2 :               cd.change().getChangeId(),
     305           2 :               cd.change().getProject().get(),
     306           2 :               cd.change().getDest().branch());
     307           2 :       ImmutableList<CommentValidationFailure> commentValidationFailures =
     308           2 :           PublishCommentUtil.findInvalidComments(
     309             :               commentValidationCtx, commentValidators, parsedCommentsForValidation);
     310           2 :       if (!commentValidationFailures.isEmpty()) {
     311           1 :         sendRejectionEmail(message, InboundEmailError.COMMENT_REJECTED);
     312           1 :         return;
     313             :       }
     314             : 
     315           2 :       Op o = new Op(PatchSet.id(cd.getId(), metadata.patchSet), parsedComments, message.id());
     316           2 :       BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.now());
     317           2 :       batchUpdate.addOp(cd.getId(), o);
     318           2 :       batchUpdate.execute();
     319           1 :     }
     320           2 :   }
     321             : 
     322             :   private class Op implements BatchUpdateOp {
     323             :     private final PatchSet.Id psId;
     324             :     private final List<MailComment> parsedComments;
     325             :     private final String tag;
     326             :     private String mailMessage;
     327             :     private List<HumanComment> comments;
     328             :     private PatchSet patchSet;
     329             :     private ChangeNotes notes;
     330             : 
     331           2 :     private Op(PatchSet.Id psId, List<MailComment> parsedComments, String messageId) {
     332           2 :       this.psId = psId;
     333           2 :       this.parsedComments = parsedComments;
     334           2 :       this.tag = "mailMessageId=" + messageId;
     335           2 :     }
     336             : 
     337             :     @Override
     338             :     public boolean updateChange(ChangeContext ctx) throws UnprocessableEntityException {
     339           2 :       patchSet = psUtil.get(ctx.getNotes(), psId);
     340           2 :       notes = ctx.getNotes();
     341           2 :       if (patchSet == null) {
     342           0 :         throw new StorageException("patch set not found: " + psId);
     343             :       }
     344             : 
     345           2 :       mailMessage =
     346           2 :           changeMessagesUtil.setChangeMessage(ctx.getUpdate(psId), generateChangeMessage(), tag);
     347           2 :       comments = new ArrayList<>();
     348           2 :       for (MailComment c : parsedComments) {
     349           2 :         comments.add(
     350           2 :             persistentCommentFromMailComment(ctx, c, targetPatchSetForComment(ctx, c, patchSet)));
     351           2 :       }
     352           2 :       commentsUtil.putHumanComments(
     353           2 :           ctx.getUpdate(ctx.getChange().currentPatchSetId()),
     354             :           HumanComment.Status.PUBLISHED,
     355             :           comments);
     356             : 
     357           2 :       return true;
     358             :     }
     359             : 
     360             :     @Override
     361             :     public void postUpdate(PostUpdateContext ctx) throws Exception {
     362           2 :       String patchSetComment = null;
     363           2 :       if (parsedComments.get(0).getType() == MailComment.CommentType.PATCHSET_LEVEL) {
     364           2 :         patchSetComment = parsedComments.get(0).getMessage();
     365             :       }
     366             :       // Send email notifications
     367           2 :       outgoingMailFactory
     368           2 :           .create(
     369             :               ctx,
     370             :               patchSet,
     371           2 :               notes.getMetaId(),
     372             :               mailMessage,
     373             :               comments,
     374             :               patchSetComment,
     375           2 :               ImmutableList.of())
     376           2 :           .sendAsync();
     377             :       // Get previous approvals from this user
     378           2 :       Map<String, Short> approvals = new HashMap<>();
     379           2 :       approvalsUtil
     380           2 :           .byPatchSetUser(notes, psId, ctx.getAccountId())
     381           2 :           .forEach(a -> approvals.put(a.label(), a.value()));
     382             :       // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
     383             :       // are always the same here.
     384           2 :       commentAdded.fire(
     385           2 :           ctx.getChangeData(notes),
     386             :           patchSet,
     387           2 :           ctx.getAccount(),
     388             :           mailMessage,
     389             :           approvals,
     390             :           approvals,
     391           2 :           ctx.getWhen());
     392           2 :     }
     393             : 
     394             :     private String generateChangeMessage() {
     395           2 :       String changeMsg = "Patch Set " + psId.get() + ":";
     396           2 :       changeMsg += "\n\n" + numComments(parsedComments.size());
     397           2 :       return changeMsg;
     398             :     }
     399             : 
     400             :     private PatchSet targetPatchSetForComment(
     401             :         ChangeContext ctx, MailComment mailComment, PatchSet current) {
     402           2 :       if (mailComment.getInReplyTo() != null) {
     403           1 :         return psUtil.get(
     404           1 :             ctx.getNotes(),
     405           1 :             PatchSet.id(ctx.getChange().getId(), mailComment.getInReplyTo().key.patchSetId));
     406             :       }
     407           2 :       return current;
     408             :     }
     409             : 
     410             :     private HumanComment persistentCommentFromMailComment(
     411             :         ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment) {
     412             :       String fileName;
     413             :       // The patch set that this comment is based on is different if this
     414             :       // comment was sent in reply to a comment on a previous patch set.
     415             :       Side side;
     416           2 :       if (mailComment.getType() == MailComment.CommentType.PATCHSET_LEVEL) {
     417           2 :         fileName = PATCHSET_LEVEL;
     418             :         // Patchset comments do not have side.
     419           2 :         side = Side.REVISION;
     420           1 :       } else if (mailComment.getInReplyTo() != null) {
     421           1 :         fileName = mailComment.getInReplyTo().key.filename;
     422           1 :         side = Side.fromShort(mailComment.getInReplyTo().side);
     423             :       } else {
     424           1 :         fileName = mailComment.getFileName();
     425           1 :         side = Side.REVISION;
     426             :       }
     427             : 
     428           2 :       HumanComment comment =
     429           2 :           commentsUtil.newHumanComment(
     430           2 :               ctx.getNotes(),
     431           2 :               ctx.getUser(),
     432           2 :               ctx.getWhen(),
     433             :               fileName,
     434           2 :               patchSetForComment.id(),
     435           2 :               (short) side.ordinal(),
     436           2 :               mailComment.getMessage(),
     437           2 :               false,
     438             :               null);
     439             : 
     440           2 :       comment.tag = tag;
     441           2 :       if (mailComment.getInReplyTo() != null) {
     442           1 :         comment.parentUuid = mailComment.getInReplyTo().key.uuid;
     443           1 :         comment.lineNbr = mailComment.getInReplyTo().lineNbr;
     444           1 :         comment.range = mailComment.getInReplyTo().range;
     445           1 :         comment.unresolved = mailComment.getInReplyTo().unresolved;
     446             :       }
     447           2 :       commentsUtil.setCommentCommitId(comment, ctx.getChange(), patchSetForComment);
     448           2 :       return comment;
     449             :     }
     450             :   }
     451             : 
     452             :   private static boolean useHtmlParser(MailMessage m) {
     453           2 :     return !Strings.isNullOrEmpty(m.htmlContent());
     454             :   }
     455             : 
     456             :   private static String numComments(int numComments) {
     457           2 :     return "(" + numComments + (numComments > 1 ? " comments)" : " comment)");
     458             :   }
     459             : 
     460             :   private Set<String> existingMessageIds(ChangeData cd) {
     461           2 :     Set<String> existingMessageIds = new HashSet<>();
     462           2 :     cd.messages().stream()
     463           2 :         .forEach(
     464             :             m -> {
     465           2 :               String messageId = CommentsUtil.extractMessageId(m.getTag());
     466           2 :               if (messageId != null) {
     467           1 :                 existingMessageIds.add(messageId);
     468             :               }
     469           2 :             });
     470           2 :     cd.publishedComments().stream()
     471           2 :         .forEach(
     472             :             c -> {
     473           2 :               String messageId = CommentsUtil.extractMessageId(c.tag);
     474           2 :               if (messageId != null) {
     475           1 :                 existingMessageIds.add(messageId);
     476             :               }
     477           2 :             });
     478           2 :     return existingMessageIds;
     479             :   }
     480             : }

Generated by: LCOV version 1.16+git.20220603.dfeb750