| // 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.common.collect.ImmutableListMultimap; |
| import com.google.common.collect.ListMultimap; |
| import com.google.common.collect.Sets; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.common.errors.EmailException; |
| import com.google.gerrit.extensions.api.changes.NotifyHandling; |
| 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.Address; |
| import com.google.gerrit.mail.EmailHeader; |
| import com.google.gerrit.mail.EmailHeader.AddressList; |
| import com.google.gerrit.mail.MailHeader; |
| import com.google.gerrit.reviewdb.client.Account; |
| import com.google.gerrit.reviewdb.client.UserIdentity; |
| import com.google.gerrit.server.account.AccountState; |
| import com.google.gerrit.server.permissions.PermissionBackendException; |
| import com.google.gerrit.server.validators.OutgoingEmailValidationListener; |
| import com.google.gerrit.server.validators.ValidationException; |
| import com.google.template.soy.data.SanitizedContent; |
| import java.net.MalformedURLException; |
| import java.net.URL; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.Date; |
| 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.util.SystemReader; |
| |
| /** Sends an email to one or more interested parties. */ |
| public abstract class OutgoingEmail { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| protected String messageClass; |
| private final Set<Account.Id> rcptTo = new HashSet<>(); |
| private final Map<String, EmailHeader> headers; |
| private final Set<Address> smtpRcptTo = new HashSet<>(); |
| private Address smtpFromAddress; |
| private StringBuilder textBody; |
| private StringBuilder htmlBody; |
| private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of(); |
| protected Map<String, Object> soyContext; |
| protected Map<String, Object> soyContextEmailData; |
| protected List<String> footers; |
| protected final EmailArguments args; |
| protected Account.Id fromId; |
| protected NotifyHandling notify = NotifyHandling.ALL; |
| |
| protected OutgoingEmail(EmailArguments ea, String mc) { |
| args = ea; |
| messageClass = mc; |
| headers = new LinkedHashMap<>(); |
| } |
| |
| public void setFrom(Account.Id id) { |
| fromId = id; |
| } |
| |
| public void setNotify(NotifyHandling notify) { |
| this.notify = requireNonNull(notify); |
| } |
| |
| public void setAccountsToNotify(ListMultimap<RecipientType, Account.Id> accountsToNotify) { |
| this.accountsToNotify = requireNonNull(accountsToNotify); |
| } |
| |
| /** |
| * Format and enqueue the message for delivery. |
| * |
| * @throws EmailException |
| */ |
| public void send() 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; |
| } |
| |
| if (NotifyHandling.NONE.equals(notify) && accountsToNotify.isEmpty()) { |
| logger.atFine().log("Not sending '%s': Notify handling is NONE", messageClass); |
| return; |
| } |
| |
| init(); |
| if (useHtml()) { |
| appendHtml(soyHtmlTemplate("HeaderHtml")); |
| } |
| format(); |
| 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().getGeneralPreferences(); |
| if (senderPrefs != null && senderPrefs.getEmailStrategy() == CC_ON_OWN_COMMENTS) { |
| // 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); |
| } else if (!accountsToNotify.containsValue(fromId) && rcptTo.remove(fromId)) { |
| // If they don't want a copy, but we queued one up anyway, |
| // drop them from the recipient lists. |
| // |
| removeUser(fromUser.get().getAccount()); |
| } |
| } |
| } |
| // 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().getAccount(); |
| GeneralPreferencesInfo prefs = thisUser.get().getGeneralPreferences(); |
| if (prefs == null || prefs.getEmailStrategy() == DISABLED) { |
| removeUser(thisUserAccount); |
| } else if (useHtml() && prefs.getEmailFormat() == EmailFormat.PLAINTEXT) { |
| removeUser(thisUserAccount); |
| smtpRcptToPlaintextOnly.add( |
| new Address(thisUserAccount.getFullName(), thisUserAccount.getPreferredEmail())); |
| } |
| } |
| 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.getEmail()); |
| } |
| } |
| smtpRcptTo.stream().forEach(a -> j.add(a.getEmail())); |
| smtpRcptToPlaintextOnly.stream().forEach(a -> j.add(a.getEmail())); |
| 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; |
| } |
| |
| 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; |
| } |
| } |
| |
| 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 |
| logger.atFine().log("Sending multipart '%s'", messageClass); |
| args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body, va.htmlBody); |
| } |
| |
| if (!smtpRcptToPlaintextOnly.isEmpty()) { |
| logger.atFine().log("Sending plaintext '%s'", messageClass); |
| // 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 |
| EmailHeader.AddressList to = new EmailHeader.AddressList(); |
| to.add(a); |
| shallowCopy.put(FieldName.TO, to); |
| } |
| args.emailSender.send(va.smtpFromAddress, smtpRcptToPlaintextOnly, shallowCopy, va.body); |
| } |
| } |
| } |
| |
| /** 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 { |
| setupSoyContext(); |
| |
| smtpFromAddress = args.fromAddressGenerator.from(fromId); |
| setHeader(FieldName.DATE, new Date()); |
| headers.put(FieldName.FROM, new EmailHeader.AddressList(smtpFromAddress)); |
| headers.put(FieldName.TO, new EmailHeader.AddressList()); |
| headers.put(FieldName.CC, new EmailHeader.AddressList()); |
| setHeader(FieldName.MESSAGE_ID, ""); |
| setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated"); |
| |
| for (RecipientType recipientType : accountsToNotify.keySet()) { |
| add(recipientType, accountsToNotify.get(recipientType)); |
| } |
| |
| setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass); |
| footers.add(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass); |
| textBody = new StringBuilder(); |
| htmlBody = new StringBuilder(); |
| |
| if (fromId != null && args.fromAddressGenerator.isGenericAddress(fromId)) { |
| appendText(getFromLine()); |
| } |
| } |
| |
| protected String getFromLine() { |
| StringBuilder f = new StringBuilder(); |
| Optional<Account> account = args.accountCache.get(fromId).map(AccountState::getAccount); |
| if (account.isPresent()) { |
| String name = account.get().getFullName(); |
| String email = account.get().getPreferredEmail(); |
| 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(); |
| } |
| |
| public String getSettingsUrl() { |
| return args.urlFormatter.get().getSettingsUrl().orElse(null); |
| } |
| |
| private String getGerritUrl() { |
| return args.urlFormatter.get().getWebUrl().orElse(null); |
| } |
| |
| /** Set a header in the outgoing message. */ |
| protected void setHeader(String name, String value) { |
| headers.put(name, new EmailHeader.String(value)); |
| } |
| |
| /** Remove a header from the outgoing message. */ |
| protected void removeHeader(String name) { |
| headers.remove(name); |
| } |
| |
| protected void setHeader(String name, Date date) { |
| headers.put(name, new EmailHeader.Date(date)); |
| } |
| |
| /** Append text to the outgoing email body. */ |
| protected void appendText(String text) { |
| if (text != null) { |
| textBody.append(text); |
| } |
| } |
| |
| /** Append html to the outgoing email body. */ |
| protected 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) { |
| if (accountId == null) { |
| return args.gerritPersonIdent.getName(); |
| } |
| |
| Optional<Account> account = args.accountCache.get(accountId).map(AccountState::getAccount); |
| String name = null; |
| if (account.isPresent()) { |
| name = account.get().getFullName(); |
| if (name == null) { |
| name = account.get().getPreferredEmail(); |
| } |
| } |
| 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. |
| */ |
| protected String getNameEmailFor(@Nullable Account.Id accountId) { |
| if (accountId == null) { |
| return args.gerritPersonIdent.getName() |
| + " <" |
| + args.gerritPersonIdent.getEmailAddress() |
| + ">"; |
| } |
| |
| Optional<Account> account = args.accountCache.get(accountId).map(AccountState::getAccount); |
| if (account.isPresent()) { |
| String name = account.get().getFullName(); |
| String email = account.get().getPreferredEmail(); |
| 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. |
| */ |
| protected 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().getAccount(); |
| String name = account.getFullName(); |
| String email = account.getPreferredEmail(); |
| if (name != null && email != null) { |
| return name + " <" + email + ">"; |
| } else if (email != null) { |
| return email; |
| } else if (name != null) { |
| return name; |
| } |
| return accountState.get().getUserName().orElse(null); |
| } |
| |
| protected boolean shouldSendMessage() { |
| 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 ((accountsToNotify == null || accountsToNotify.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 true; |
| } |
| |
| /** Schedule this message for delivery to the listed accounts. */ |
| protected void add(RecipientType rt, Collection<Account.Id> list) { |
| add(rt, list, false); |
| } |
| |
| /** Schedule this message for delivery to the listed accounts. */ |
| protected void add(RecipientType rt, Collection<Account.Id> list, boolean override) { |
| for (final Account.Id id : list) { |
| add(rt, id, override); |
| } |
| } |
| |
| /** Schedule this message for delivery to the listed address. */ |
| protected void addByEmail(RecipientType rt, Collection<Address> list) { |
| addByEmail(rt, list, false); |
| } |
| |
| /** Schedule this message for delivery to the listed address. */ |
| protected void addByEmail(RecipientType rt, Collection<Address> list, boolean override) { |
| for (final Address id : list) { |
| add(rt, id, override); |
| } |
| } |
| |
| protected void add(RecipientType rt, UserIdentity who) { |
| add(rt, who, false); |
| } |
| |
| protected void add(RecipientType rt, UserIdentity who, boolean override) { |
| if (who != null && who.getAccount() != null) { |
| add(rt, who.getAccount(), override); |
| } |
| } |
| |
| /** Schedule delivery of this message to the given account. */ |
| protected void add(RecipientType rt, Account.Id to) { |
| add(rt, to, false); |
| } |
| |
| protected void add(RecipientType rt, Account.Id to, boolean override) { |
| try { |
| if (!rcptTo.contains(to) && isVisibleTo(to)) { |
| rcptTo.add(to); |
| add(rt, toAddress(to), override); |
| } |
| } catch (PermissionBackendException e) { |
| logger.atSevere().withCause(e).log("Error reading database for account: %s", to); |
| } |
| } |
| |
| /** |
| * @param to account. |
| * @throws PermissionBackendException |
| * @return whether this email is visible to the given account. |
| */ |
| protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException { |
| return true; |
| } |
| |
| /** Schedule delivery of this message to the given account. */ |
| protected void add(RecipientType rt, Address addr) { |
| add(rt, addr, false); |
| } |
| |
| protected void add(RecipientType rt, Address addr, boolean override) { |
| if (addr != null && addr.getEmail() != null && addr.getEmail().length() > 0) { |
| if (!args.validator.isValid(addr.getEmail())) { |
| logger.atWarning().log("Not emailing %s (invalid email address)", addr.getEmail()); |
| } else if (args.emailSender.canEmail(addr.getEmail())) { |
| if (!smtpRcptTo.add(addr)) { |
| if (!override) { |
| return; |
| } |
| ((EmailHeader.AddressList) headers.get(FieldName.TO)).remove(addr.getEmail()); |
| ((EmailHeader.AddressList) headers.get(FieldName.CC)).remove(addr.getEmail()); |
| } |
| switch (rt) { |
| case TO: |
| ((EmailHeader.AddressList) headers.get(FieldName.TO)).add(addr); |
| break; |
| case CC: |
| ((EmailHeader.AddressList) headers.get(FieldName.CC)).add(addr); |
| break; |
| case BCC: |
| break; |
| } |
| } |
| } |
| } |
| |
| private Address toAddress(Account.Id id) { |
| Optional<Account> accountState = args.accountCache.get(id).map(AccountState::getAccount); |
| if (!accountState.isPresent()) { |
| return null; |
| } |
| |
| Account account = accountState.get(); |
| String e = account.getPreferredEmail(); |
| if (!account.isActive() || e == null) { |
| return null; |
| } |
| return new Address(account.getFullName(), e); |
| } |
| |
| protected void setupSoyContext() { |
| soyContext = new HashMap<>(); |
| footers = new ArrayList<>(); |
| |
| soyContext.put("messageClass", messageClass); |
| soyContext.put("footers", footers); |
| |
| soyContextEmailData = new HashMap<>(); |
| soyContextEmailData.put("settingsUrl", getSettingsUrl()); |
| soyContextEmailData.put("instanceName", getInstanceName()); |
| soyContextEmailData.put("gerritHost", getGerritHost()); |
| soyContextEmailData.put("gerritUrl", getGerritUrl()); |
| soyContext.put("email", soyContextEmailData); |
| } |
| |
| private String getInstanceName() { |
| return args.instanceNameProvider.get(); |
| } |
| |
| private String soyTemplate(String name, SanitizedContent.ContentKind kind) { |
| return args.soyTofu |
| .newRenderer("com.google.gerrit.server.mail.template." + name) |
| .setContentKind(kind) |
| .setData(soyContext) |
| .render(); |
| } |
| |
| protected String textTemplate(String name) { |
| return soyTemplate(name, SanitizedContent.ContentKind.TEXT); |
| } |
| |
| protected String soyHtmlTemplate(String name) { |
| return soyTemplate(name, SanitizedContent.ContentKind.HTML); |
| } |
| |
| protected void removeUser(Account user) { |
| String fromEmail = user.getPreferredEmail(); |
| for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) { |
| if (j.next().getEmail().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); |
| } |
| } |
| } |
| |
| protected final boolean useHtml() { |
| return args.settings.html && supportsHtml(); |
| } |
| |
| /** Override this method to enable HTML in a subclass. */ |
| protected boolean supportsHtml() { |
| return false; |
| } |
| } |