| // 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; |
| |
| import com.google.gerrit.client.data.ProjectCache; |
| import com.google.gerrit.client.reviewdb.Account; |
| import com.google.gerrit.client.reviewdb.AccountGroupMember; |
| import com.google.gerrit.client.reviewdb.AccountProjectWatch; |
| import com.google.gerrit.client.reviewdb.Change; |
| import com.google.gerrit.client.reviewdb.ChangeApproval; |
| import com.google.gerrit.client.reviewdb.ChangeMessage; |
| import com.google.gerrit.client.reviewdb.Patch; |
| import com.google.gerrit.client.reviewdb.PatchLineComment; |
| import com.google.gerrit.client.reviewdb.PatchSet; |
| import com.google.gerrit.client.reviewdb.PatchSetInfo; |
| import com.google.gerrit.client.reviewdb.Project; |
| import com.google.gerrit.client.reviewdb.ReviewDb; |
| import com.google.gerrit.client.reviewdb.UserIdentity; |
| import com.google.gerrit.client.rpc.Common; |
| import com.google.gwtorm.client.OrmException; |
| |
| import org.spearce.jgit.lib.PersonIdent; |
| |
| import java.io.UnsupportedEncodingException; |
| import java.net.InetAddress; |
| import java.net.MalformedURLException; |
| import java.net.URL; |
| import java.net.UnknownHostException; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.Date; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Set; |
| |
| import javax.mail.Address; |
| import javax.mail.MessagingException; |
| import javax.mail.Transport; |
| import javax.mail.Message.RecipientType; |
| import javax.mail.internet.AddressException; |
| import javax.mail.internet.InternetAddress; |
| import javax.servlet.http.HttpServletRequest; |
| |
| public class ChangeMail { |
| private final GerritServer server; |
| private final javax.mail.Session transport; |
| private final Change change; |
| private final String projectName; |
| private final HashSet<Account.Id> rcptTo = new HashSet<Account.Id>(); |
| private MimeMessage msg; |
| private StringBuilder body; |
| private boolean inFooter; |
| |
| private String myUrl; |
| private Account.Id fromId; |
| private PatchSet patchSet; |
| private PatchSetInfo patchSetInfo; |
| private ChangeMessage message; |
| private List<PatchLineComment> comments = Collections.emptyList(); |
| private final Set<Account.Id> reviewers = new HashSet<Account.Id>(); |
| private final Set<Account.Id> extraCC = new HashSet<Account.Id>(); |
| private ReviewDb db; |
| |
| public ChangeMail(final GerritServer gs, final Change c) { |
| server = gs; |
| transport = server.getOutgoingMail(); |
| change = c; |
| projectName = change.getDest().getParentKey().get(); |
| } |
| |
| public void setFrom(final Account.Id id) { |
| fromId = id; |
| } |
| |
| public void setHttpServletRequest(final HttpServletRequest req) { |
| final StringBuffer url = req.getRequestURL(); |
| final int rpc = url.indexOf("/rpc/"); |
| if (rpc >= 0) { |
| url.setLength(rpc + 1); // cut "rpc/..." |
| } |
| if (url.length() == 0 || url.charAt(url.length() - 1) != '/') { |
| url.append('/'); |
| } |
| myUrl = url.toString(); |
| } |
| |
| public void setPatchSet(final PatchSet ps, final PatchSetInfo psi) { |
| patchSet = ps; |
| patchSetInfo = psi; |
| } |
| |
| public void setChangeMessage(final ChangeMessage cm) { |
| message = cm; |
| } |
| |
| public void setPatchLineComments(final List<PatchLineComment> plc) { |
| comments = plc; |
| } |
| |
| public void addReviewers(final Collection<Account.Id> cc) { |
| reviewers.addAll(cc); |
| } |
| |
| public void addExtraCC(final Collection<Account.Id> cc) { |
| extraCC.addAll(cc); |
| } |
| |
| public void setReviewDb(final ReviewDb d) { |
| db = d; |
| } |
| |
| public void sendNewChange() throws MessagingException { |
| if (begin("newchange")) { |
| newChangeTo(); |
| if (!haveRcptTo()) { |
| // No destinations at this point makes it very moot to mail, |
| // nobody was interested in the change or was told to look |
| // at it by the caller. |
| // |
| return; |
| } |
| newChangeCc(); |
| |
| body.append("New change "); |
| body.append(change.getChangeId()); |
| body.append(" for "); |
| body.append(change.getDest().getShortName()); |
| body.append(":\n\n"); |
| |
| newChangePatchSetInfo(); |
| newChangeFooter(); |
| |
| msg.setMessageID(changeMessageThreadId()); |
| send(); |
| } |
| } |
| |
| private void newChangePatchSetInfo() { |
| if (changeUrl() != null) { |
| body.append(" "); |
| body.append(changeUrl()); |
| body.append("\n\n"); |
| } |
| |
| if (patchSetInfo != null) { |
| body.append(patchSetInfo.getMessage().trim()); |
| body.append("\n\n"); |
| } else { |
| body.append(change.getSubject().trim()); |
| body.append("\n\n"); |
| } |
| |
| if (db != null && patchSet != null) { |
| body.append("---\n"); |
| try { |
| for (Patch p : db.patches().byPatchSet(patchSet.getId())) { |
| body.append(p.getChangeType().getCode()); |
| body.append(' '); |
| body.append(p.getFileName()); |
| body.append('\n'); |
| } |
| } catch (OrmException e) { |
| // Don't bother including the files if we get a failure, |
| // ensure we at least send the notification message. |
| } |
| body.append("\n"); |
| } |
| } |
| |
| private void newChangeFooter() { |
| if (changeUrl() != null) { |
| openFooter(); |
| body.append("View this change at "); |
| body.append(changeUrl()); |
| body.append("\n"); |
| } |
| } |
| |
| public void sendNewPatchSet() throws MessagingException { |
| if (begin("newpatchset")) { |
| newChangeTo(); |
| if (!haveRcptTo()) { |
| // No destinations at this point makes it very moot to mail, |
| // nobody was interested in the change or was told to look |
| // at it by the caller. |
| // |
| return; |
| } |
| newChangeCc(); |
| |
| body.append("Uploaded replacement patch set "); |
| body.append(patchSet.getPatchSetId()); |
| body.append(" for change "); |
| body.append(change.getChangeId()); |
| body.append(":\n\n"); |
| |
| newChangePatchSetInfo(); |
| newChangeFooter(); |
| initInReplyToChange(); |
| send(); |
| } |
| } |
| |
| public void sendComment() throws MessagingException { |
| if (begin("comment")) { |
| if (message != null) { |
| body.append(message.getMessage().trim()); |
| if (body.length() > 0) { |
| body.append("\n\n"); |
| } |
| } |
| |
| if (!comments.isEmpty()) { |
| body.append("Comments on Patch Set "); |
| body.append(patchSet.getPatchSetId()); |
| body.append(":\n\n"); |
| } |
| |
| String priorFile = ""; |
| for (final PatchLineComment c : comments) { |
| final String fn = c.getKey().getParentKey().get(); |
| if (!fn.equals(priorFile)) { |
| body.append("....................................................\n"); |
| body.append("File "); |
| body.append(fn); |
| body.append("\n"); |
| priorFile = fn; |
| } |
| body.append("Line "); |
| body.append(c.getLine()); |
| body.append("\n"); |
| body.append(c.getMessage().trim()); |
| body.append("\n\n"); |
| } |
| |
| if (body.length() == 0) { |
| // If we have no body, don't bother generating an email. |
| // |
| return; |
| } |
| |
| if (changeUrl() != null) { |
| openFooter(); |
| body.append("To respond visit "); |
| body.append(changeUrl()); |
| body.append("\n"); |
| } |
| |
| initInReplyToChange(); |
| commentTo(); |
| send(); |
| } |
| } |
| |
| public void sendRequestReview() throws MessagingException { |
| if (begin("requestReview")) { |
| final Account a = Common.getAccountCache().get(fromId); |
| if (a == null || a.getFullName() == null || a.getFullName().length() == 0) { |
| body.append("A Gerrit user"); |
| } else { |
| body.append(a.getFullName()); |
| } |
| body.append(" has requested that you review a change:\n\n"); |
| body.append(change.getChangeId()); |
| body.append(" - "); |
| body.append(change.getSubject()); |
| body.append("\n\n"); |
| |
| if (changeUrl() != null) { |
| openFooter(); |
| body.append("To respond visit "); |
| body.append(changeUrl()); |
| body.append("\n"); |
| } |
| |
| initInReplyToChange(); |
| add(RecipientType.TO, reviewers); |
| add(RecipientType.CC, extraCC); |
| if (fromId != null) { |
| add(RecipientType.CC, fromId); |
| } |
| send(); |
| } |
| } |
| |
| private void newChangeTo() throws MessagingException { |
| add(RecipientType.TO, reviewers); |
| add(RecipientType.CC, extraCC); |
| if (patchSetInfo != null) { |
| // Make sure the author/committer get notice of a change that |
| // they will be blamed later on for writing. |
| // |
| add(RecipientType.CC, patchSetInfo.getAuthor()); |
| add(RecipientType.CC, patchSetInfo.getCommitter()); |
| } |
| |
| final ProjectCache.Entry cacheEntry = |
| Common.getProjectCache().get(change.getDest().getParentKey()); |
| if (cacheEntry == null) { |
| return; |
| } |
| try { |
| // Try to mark interested owners with a TO and not a BCC line. |
| // |
| final Set<Account.Id> toNotBCC = new HashSet<Account.Id>(); |
| for (AccountGroupMember m : db.accountGroupMembers().byGroup( |
| cacheEntry.getProject().getOwnerGroupId())) { |
| toNotBCC.add(m.getAccountId()); |
| } |
| |
| // BCC anyone who has interest in this project's changes |
| // |
| for (AccountProjectWatch w : db.accountProjectWatches().notifyNewChanges( |
| cacheEntry.getProject().getId())) { |
| if (toNotBCC.contains(w.getAccountId())) { |
| add(RecipientType.TO, w.getAccountId()); |
| } else { |
| add(RecipientType.BCC, w.getAccountId()); |
| } |
| } |
| } catch (OrmException 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. |
| } |
| } |
| |
| private void newChangeCc() throws MessagingException { |
| // CC the owner/uploader, but in truth these should always match |
| // the sender too. add will strip duplicates (if any). |
| // |
| add(RecipientType.CC, change.getOwner()); |
| if (patchSet != null) { |
| add(RecipientType.CC, patchSet.getUploader()); |
| } |
| ccSender(); |
| } |
| |
| private void commentTo() throws MessagingException { |
| // Always to the owner/uploader/author/committer. These people |
| // have a vested interest in the change and any remarks made. |
| // |
| add(RecipientType.TO, change.getOwner()); |
| if (patchSet != null) { |
| add(RecipientType.TO, patchSet.getUploader()); |
| } |
| if (patchSetInfo != null) { |
| add(RecipientType.TO, patchSetInfo.getAuthor()); |
| add(RecipientType.TO, patchSetInfo.getCommitter()); |
| } |
| add(RecipientType.CC, reviewers); |
| add(RecipientType.CC, extraCC); |
| |
| if (db == null) { |
| // We need a database handle to fetch the interest list. |
| // |
| return; |
| } |
| |
| try { |
| // CC anyone else who has posted an approval mark on this change |
| // |
| for (ChangeApproval ap : db.changeApprovals().byChange(change.getId())) { |
| add(RecipientType.CC, ap.getAccountId()); |
| } |
| |
| // BCC anyone else who has interest in this project's changes |
| // |
| final Project.Id projectId = projectId(); |
| if (projectId != null) { |
| for (AccountProjectWatch w : db.accountProjectWatches() |
| .notifyAllComments(projectId)) { |
| add(RecipientType.BCC, w.getAccountId()); |
| } |
| } |
| } catch (OrmException 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. |
| } |
| } |
| |
| private boolean begin(final String messageClass) throws MessagingException { |
| if (transport != null) { |
| msg = new MimeMessage(transport); |
| if (message != null && message.getWrittenOn() != null) { |
| msg.setSentDate(new Date(message.getWrittenOn().getTime())); |
| } else { |
| msg.setSentDate(new Date()); |
| } |
| initFrom(); |
| initUserAgent(); |
| initListId(); |
| initChangeUrl(); |
| initChangeId(); |
| initCommitId(); |
| initMessageType(messageClass); |
| initSubject(); |
| body = new StringBuilder(); |
| inFooter = false; |
| return true; |
| } |
| return false; |
| } |
| |
| private void initFrom() throws MessagingException, AddressException { |
| Address addr; |
| if (fromId != null) { |
| addr = toAddress(fromId); |
| } else { |
| final PersonIdent pi = server.newGerritPersonIdent(); |
| try { |
| addr = new InternetAddress(pi.getName(), pi.getEmailAddress()); |
| } catch (UnsupportedEncodingException e) { |
| addr = new InternetAddress(pi.getEmailAddress()); |
| } |
| } |
| msg.setFrom(addr); |
| } |
| |
| private void initUserAgent() throws MessagingException { |
| msg.setHeader("User-Agent", "Gerrit/2"); |
| } |
| |
| private void initListId() throws MessagingException { |
| // Set a reasonable list id so that filters can be used to sort messages |
| // |
| final StringBuilder listid = new StringBuilder(); |
| listid.append("gerrit-"); |
| listid.append(projectName.replace('/', '-')); |
| listid.append("@"); |
| listid.append(gerritHost()); |
| |
| final String listidStr = listid.toString(); |
| msg.setHeader("Mailing-List", "list " + listidStr); |
| msg.setHeader("List-Id", "<" + listidStr.replace('@', '.') + ">"); |
| if (settingsUrl() != null) { |
| msg.setHeader("List-Unsubscribe", "<" + settingsUrl() + ">"); |
| } |
| } |
| |
| private void initChangeUrl() throws MessagingException { |
| final String u = changeUrl(); |
| if (u != null) { |
| msg.setHeader("X-Gerrit-ChangeURL", "<" + u + ">"); |
| } |
| } |
| |
| private void initChangeId() throws MessagingException { |
| msg.setHeader("X-Gerrit-ChangeId", "" + change.getChangeId()); |
| } |
| |
| private void initCommitId() throws MessagingException { |
| if (patchSet != null && patchSet.getRevision() != null |
| && patchSet.getRevision().get() != null |
| && patchSet.getRevision().get().length() > 0) { |
| msg.setHeader("X-Gerrit-Commit", patchSet.getRevision().get()); |
| } |
| } |
| |
| private void initMessageType(final String messageClass) |
| throws MessagingException { |
| msg.setHeader("X-Gerrit-MessageType", messageClass); |
| } |
| |
| private void initInReplyToChange() throws MessagingException { |
| final String id = changeMessageThreadId(); |
| msg.setHeader("In-Reply-To", id); |
| msg.setHeader("References", id); |
| } |
| |
| private void initSubject() throws MessagingException { |
| final StringBuilder subj = new StringBuilder(); |
| subj.append("Change "); |
| subj.append(change.getChangeId()); |
| subj.append(": ("); |
| subj.append(projectName); |
| subj.append(") "); |
| if (change.getSubject().length() > 60) { |
| subj.append(change.getSubject().substring(0, 60)); |
| subj.append("..."); |
| } else { |
| subj.append(change.getSubject()); |
| } |
| msg.setSubject(subj.toString()); |
| } |
| |
| private String gerritHost() { |
| if (server.getCanonicalURL() != null) { |
| try { |
| return new URL(server.getCanonicalURL()).getHost(); |
| } catch (MalformedURLException e) { |
| // Try something else. |
| } |
| } |
| |
| if (myUrl != null) { |
| try { |
| return new URL(myUrl).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. |
| // |
| try { |
| return InetAddress.getLocalHost().getCanonicalHostName(); |
| } catch (UnknownHostException e) { |
| return "localhost"; |
| } |
| } |
| |
| private String changeUrl() { |
| if (gerritUrl() != null) { |
| final StringBuilder r = new StringBuilder(); |
| r.append(gerritUrl()); |
| r.append(change.getChangeId()); |
| return r.toString(); |
| } |
| return null; |
| } |
| |
| private String settingsUrl() { |
| if (gerritUrl() != null) { |
| final StringBuilder r = new StringBuilder(); |
| r.append(gerritUrl()); |
| r.append("settings"); |
| return r.toString(); |
| } |
| return null; |
| } |
| |
| private String gerritUrl() { |
| if (server.getCanonicalURL() != null) { |
| return server.getCanonicalURL(); |
| } |
| return myUrl; |
| } |
| |
| private String changeMessageThreadId() { |
| final StringBuilder r = new StringBuilder(); |
| r.append('<'); |
| r.append("gerrit"); |
| r.append('.'); |
| r.append(change.getCreatedOn().getTime()); |
| r.append('.'); |
| r.append(change.getChangeId()); |
| r.append('@'); |
| r.append(gerritHost()); |
| r.append('>'); |
| return r.toString(); |
| } |
| |
| private void openFooter() { |
| if (!inFooter) { |
| inFooter = true; |
| body.append("-- \n"); |
| } |
| } |
| |
| private void send() throws MessagingException { |
| if (haveRcptTo()) { |
| ccSender(); |
| if (settingsUrl() != null) { |
| openFooter(); |
| body.append("To unsubscribe, visit "); |
| body.append(settingsUrl()); |
| body.append("\n"); |
| } |
| msg.setText(body.toString(), "UTF-8"); |
| Transport.send(msg); |
| } |
| } |
| |
| private boolean haveRcptTo() { |
| if (rcptTo.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. |
| // |
| return false; |
| } |
| |
| if (rcptTo.size() == 1 && rcptTo.contains(fromId)) { |
| // If the only recipient is also the sender, don't bother. |
| // |
| return false; |
| } |
| |
| return true; |
| } |
| |
| private Project.Id projectId() { |
| final ProjectCache.Entry r; |
| |
| r = Common.getProjectCache().get(change.getDest().getParentKey()); |
| return r != null ? r.getProject().getId() : null; |
| } |
| |
| private void ccSender() throws MessagingException { |
| if (fromId != null) { |
| // If we are impersonating a user, make sure they receive a CC of |
| // this message so they can always review and audit what we sent |
| // on their behalf to others. |
| // |
| add(RecipientType.CC, fromId); |
| } |
| } |
| |
| private void add(final RecipientType rt, final Collection<Account.Id> list) |
| throws MessagingException { |
| for (final Account.Id id : list) { |
| add(rt, id); |
| } |
| } |
| |
| private void add(final RecipientType rt, final UserIdentity who) |
| throws MessagingException { |
| if (who != null && who.getAccount() != null) { |
| add(rt, who.getAccount()); |
| } |
| } |
| |
| private void add(final RecipientType rt, final Account.Id to) |
| throws MessagingException { |
| if (!rcptTo.add(to)) { |
| return; |
| } |
| |
| final Address addr = toAddress(to); |
| if (addr != null) { |
| msg.addRecipient(rt, addr); |
| } |
| } |
| |
| private Address toAddress(final Account.Id id) throws AddressException { |
| final Account a = Common.getAccountCache().get(id); |
| if (a == null) { |
| return null; |
| } |
| |
| final String e = a.getPreferredEmail(); |
| if (e == null) { |
| return null; |
| } |
| |
| try { |
| final String an = a.getFullName(); |
| return new InternetAddress(e, an); |
| } catch (UnsupportedEncodingException e1) { |
| return new InternetAddress(e); |
| } |
| } |
| } |