| // Copyright (C) 2016 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package com.google.gerrit.server.mail.send; |
| |
| import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS; |
| import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED; |
| import static java.util.Objects.requireNonNull; |
| |
| import com.google.auto.factory.AutoFactory; |
| import com.google.auto.factory.Provided; |
| import com.google.common.base.Throwables; |
| import com.google.common.collect.Sets; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.common.UsedAt; |
| import com.google.gerrit.entities.Account; |
| import com.google.gerrit.entities.Address; |
| import com.google.gerrit.entities.EmailHeader; |
| import com.google.gerrit.entities.EmailHeader.AddressList; |
| import com.google.gerrit.entities.EmailHeader.StringEmailHeader; |
| import com.google.gerrit.exceptions.EmailException; |
| import com.google.gerrit.extensions.api.changes.RecipientType; |
| import com.google.gerrit.extensions.client.GeneralPreferencesInfo; |
| import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat; |
| import com.google.gerrit.mail.MailHeader; |
| import com.google.gerrit.server.CurrentUser; |
| import com.google.gerrit.server.account.AccountState; |
| import com.google.gerrit.server.change.NotifyResolver; |
| import com.google.gerrit.server.permissions.PermissionBackendException; |
| import com.google.gerrit.server.update.RetryableAction.ActionType; |
| import com.google.gerrit.server.validators.OutgoingEmailValidationListener; |
| import com.google.gerrit.server.validators.ValidationException; |
| import com.google.template.soy.data.SanitizedContent; |
| import com.google.template.soy.jbcsrc.api.SoySauce; |
| import java.net.MalformedURLException; |
| import java.net.URL; |
| import java.time.Instant; |
| import java.util.ArrayList; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.Iterator; |
| import java.util.LinkedHashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Optional; |
| import java.util.Set; |
| import java.util.StringJoiner; |
| import org.apache.james.mime4j.dom.field.FieldName; |
| import org.eclipse.jgit.lib.PersonIdent; |
| import org.eclipse.jgit.util.SystemReader; |
| |
| /** Represents an email notification for some event that can be sent to interested parties. */ |
| @AutoFactory |
| public final class OutgoingEmail { |
| |
| /** Provides content, recipients and any customizations of the email. */ |
| public interface EmailDecorator { |
| /** |
| * Stores the reference to the email for the subsequent calls. |
| * |
| * <p>Both init and populateEmailContent can be called multiply times in case of retries. Init |
| * is therefore responsible for clearing up any changes which are not idempotent and |
| * initializing data for use in populateEmailContent. |
| * |
| * <p>Can be used to adjust any of the behaviour of the {@link |
| * OutgoingEmail#populateEmailContent}. |
| */ |
| void init(OutgoingEmail email) throws EmailException; |
| |
| /** |
| * Populate headers, recipients and body of the email. |
| * |
| * <p>Method operates on the email provided in the init method. |
| * |
| * <p>By default, all the contents and parameters of the email should be set in this method. |
| */ |
| void populateEmailContent() throws EmailException; |
| |
| /** If returns false email is not sent to any recipients. */ |
| default boolean shouldSendMessage() { |
| return true; |
| } |
| |
| /** |
| * Evaluates whether account can be added to the list of recipients. |
| * |
| * @param rcpt the recipient for which it should be checker whether it can be added to the list |
| * of recipients |
| * @throws PermissionBackendException thrown if checking permissions fails |
| */ |
| default boolean isRecipientAllowed(Account.Id rcpt) throws PermissionBackendException { |
| return true; |
| } |
| |
| /** |
| * Evaluates whether email can be added to the list of recipients. |
| * |
| * @param rcpt the recipient for which it should be checker whether it can be added to the list |
| * of recipients |
| * @throws PermissionBackendException thrown if checking permissions fails |
| */ |
| default boolean isRecipientAllowed(Address rcpt) throws PermissionBackendException { |
| return true; |
| } |
| } |
| |
| private static final String SOY_TEMPLATE_NAMESPACE = "com.google.gerrit.server.mail.template"; |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| private String messageClass; |
| private final Set<Account.Id> rcptTo = new HashSet<>(); |
| private final Map<String, EmailHeader> headers = new LinkedHashMap<>(); |
| private final Set<Address> smtpRcptTo = new HashSet<>(); |
| private final Set<Address> smtpBccRcptTo = new HashSet<>(); |
| private Address smtpFromAddress; |
| private StringBuilder textBody; |
| private ArrayList<SanitizedContent> htmlBodySections; |
| private MessageIdGenerator.MessageId messageId; |
| private Map<String, Object> soyContext; |
| private Map<String, Object> soyContextEmailData; |
| private List<String> footers; |
| private final EmailArguments args; |
| private Account.Id fromId; |
| private NotifyResolver.Result notify = NotifyResolver.Result.all(); |
| private final EmailDecorator templateProvider; |
| private ArrayList<EmailResource> htmlResources; |
| |
| public OutgoingEmail( |
| @Provided EmailArguments args, String messageClass, EmailDecorator templateProvider) { |
| this.args = args; |
| this.messageClass = messageClass; |
| this.templateProvider = templateProvider; |
| } |
| |
| /** Specify the account that triggered the notification. */ |
| public void setFrom(Account.Id id) { |
| fromId = id; |
| } |
| |
| /** Get the account that triggered the notification. */ |
| public Account.Id getFrom() { |
| return fromId; |
| } |
| |
| /** Set how widely the email notification is allowed to be sent. */ |
| public void setNotify(NotifyResolver.Result notify) { |
| this.notify = requireNonNull(notify); |
| } |
| |
| /** Returns the setting that controls how widely the email notification is allowed to be sent. */ |
| public NotifyResolver.Result getNotify() { |
| return this.notify; |
| } |
| |
| /** Set identifier for the email. Every email must have one. */ |
| public void setMessageId(MessageIdGenerator.MessageId messageId) { |
| this.messageId = messageId; |
| } |
| |
| private String constructTextEmail() { |
| soyContext.put("body", textBody.toString()); |
| soyContext.put("footer", textTemplate("Footer")); |
| return textTemplate("Email"); |
| } |
| |
| private String constructHtmlEmail() { |
| soyContext.put("body_sections_html", htmlBodySections); |
| soyContext.put("footer_html", soyHtmlTemplate("FooterHtml")); |
| return soyHtmlTemplate("EmailHtml").toString(); |
| } |
| |
| /** Format and enqueue the message for delivery. */ |
| public void send() throws EmailException { |
| try { |
| args.retryHelper |
| .action( |
| ActionType.SEND_EMAIL, |
| "sendEmail", |
| () -> { |
| sendImpl(); |
| return null; |
| }) |
| .retryWithTrace(Exception.class::isInstance) |
| .call(); |
| } catch (Exception e) { |
| Throwables.throwIfUnchecked(e); |
| Throwables.throwIfInstanceOf(e, EmailException.class); |
| throw new EmailException("sending email failed", e); |
| } |
| } |
| |
| private void sendImpl() throws EmailException { |
| if (!args.emailSender.isEnabled()) { |
| // Server has explicitly disabled email sending. |
| // |
| logger.atFine().log( |
| "Not sending '%s': Email sending is disabled by server config", messageClass); |
| return; |
| } |
| |
| init(); |
| if (!notify.shouldNotify()) { |
| logger.atFine().log("Not sending '%s': Notify handling is NONE", messageClass); |
| return; |
| } |
| populateEmailContent(); |
| if (messageId == null) { |
| throw new IllegalStateException("All emails must have a messageId"); |
| } |
| |
| Set<Address> smtpRcptToPlaintextOnly = new HashSet<>(); |
| if (shouldSendMessage()) { |
| if (fromId != null) { |
| Optional<AccountState> fromUser = args.accountCache.get(fromId); |
| if (fromUser.isPresent()) { |
| GeneralPreferencesInfo senderPrefs = fromUser.get().generalPreferences(); |
| CurrentUser user = args.currentUserProvider.get(); |
| boolean isImpersonating = user.isIdentifiedUser() && user.isImpersonating(); |
| if (isImpersonating && user.getAccountId() != fromId) { |
| // This should not be possible, if this is the case it means the RequestContext is not |
| // set up correctly. |
| throw new EmailException( |
| String.format( |
| "User %s is sending email from %s, while acting on behalf of %s", |
| user.asIdentifiedUser().getRealUser().getAccountId(), |
| fromId, |
| user.getAccountId())); |
| } |
| if (senderPrefs != null && senderPrefs.getEmailStrategy() == CC_ON_OWN_COMMENTS) { |
| // Include the sender in email if they enabled email notifications on their own |
| // comments. |
| // |
| logger.atFine().log( |
| "CC email sender %s because the email strategy of this user is %s", |
| fromUser.get().account().id(), CC_ON_OWN_COMMENTS); |
| addByAccountId(RecipientType.CC, fromId); |
| } else if (isImpersonating) { |
| // If we are impersonating a user, make sure they receive a CC of |
| // this message regardless of email strategy, unless email notifications are explicitly |
| // disabled for this user. This way they can always review and audit what we sent |
| // on their behalf to others. |
| logger.atFine().log( |
| "CC email sender %s because the email is sent on behalf of and email notifications" |
| + " are enabled for this user.", |
| fromUser.get().account().id()); |
| addByAccountId(RecipientType.CC, fromId); |
| |
| } else if (!notify.accounts().containsValue(fromId) && rcptTo.remove(fromId)) { |
| // If they don't want a copy, but we queued one up anyway, |
| // drop them from the recipient lists, but only if the user is not being impersonated. |
| // |
| logger.atFine().log( |
| "Not CCing email sender %s because the email strategy of this user is not %s but" |
| + " %s", |
| fromUser.get().account().id(), |
| CC_ON_OWN_COMMENTS, |
| senderPrefs != null ? senderPrefs.getEmailStrategy() : null); |
| removeUser(fromUser.get().account()); |
| } |
| } |
| } |
| // Check the preferences of all recipients. If any user has disabled |
| // his email notifications then drop him from recipients' list. |
| // In addition, check if users only want to receive plaintext email. |
| for (Account.Id id : rcptTo) { |
| Optional<AccountState> thisUser = args.accountCache.get(id); |
| if (thisUser.isPresent()) { |
| Account thisUserAccount = thisUser.get().account(); |
| GeneralPreferencesInfo prefs = thisUser.get().generalPreferences(); |
| if (prefs == null || prefs.getEmailStrategy() == DISABLED) { |
| logger.atFine().log( |
| "Not emailing account %s because user has set email strategy to %s", id, DISABLED); |
| removeUser(thisUserAccount); |
| } else if (useHtml() && prefs.getEmailFormat() == EmailFormat.PLAINTEXT) { |
| logger.atFine().log( |
| "Removing account %s from HTML email because user prefers plain text emails", id); |
| removeUser(thisUserAccount); |
| smtpRcptToPlaintextOnly.add( |
| Address.create(thisUserAccount.fullName(), thisUserAccount.preferredEmail())); |
| } |
| } |
| if (smtpRcptTo.isEmpty() && smtpRcptToPlaintextOnly.isEmpty()) { |
| logger.atFine().log("Not sending '%s': No SMTP recipients", messageClass); |
| return; |
| } |
| } |
| |
| // Set Reply-To only if it hasn't been set by a child class |
| // Reply-To will already be populated for the message types where Gerrit supports |
| // inbound email replies. |
| if (!headers.containsKey(FieldName.REPLY_TO)) { |
| StringJoiner j = new StringJoiner(", "); |
| if (fromId != null) { |
| Address address = toAddress(fromId); |
| if (address != null) { |
| j.add(address.email()); |
| } |
| } |
| // For users who prefer plaintext, this comes at the cost of not being |
| // listed in the multipart To and Cc headers. We work around this by adding |
| // all users to the Reply-To address in both the plaintext and multipart |
| // email. We should exclude any BCC addresses from reply-to, because they should be |
| // invisible to other recipients. |
| Sets.difference(Sets.union(smtpRcptTo, smtpRcptToPlaintextOnly), smtpBccRcptTo).stream() |
| .forEach(a -> j.add(a.email())); |
| setHeader(FieldName.REPLY_TO, j.toString()); |
| } |
| |
| OutgoingEmailValidationListener.Args va = new OutgoingEmailValidationListener.Args(); |
| va.messageClass = messageClass; |
| va.smtpFromAddress = smtpFromAddress; |
| va.smtpRcptTo = smtpRcptTo; |
| va.headers = headers; |
| va.body = constructTextEmail(); |
| |
| if (useHtml()) { |
| va.htmlBody = constructHtmlEmail(); |
| } else { |
| va.htmlBody = null; |
| } |
| |
| Set<Address> intersection = Sets.intersection(va.smtpRcptTo, smtpRcptToPlaintextOnly); |
| if (!intersection.isEmpty()) { |
| logger.atSevere().log("Email '%s' will be sent twice to %s", messageClass, intersection); |
| } |
| if (!va.smtpRcptTo.isEmpty()) { |
| // Send multipart message |
| addMessageId(va, "-HTML"); |
| if (!validateEmail(va)) return; |
| logger.atFine().log( |
| "Sending multipart '%s' from %s to %s", |
| messageClass, va.smtpFromAddress, va.smtpRcptTo); |
| args.emailSender.send( |
| va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body, va.htmlBody, htmlResources); |
| } |
| if (!smtpRcptToPlaintextOnly.isEmpty()) { |
| addMessageId(va, "-PLAIN"); |
| // Send plaintext message |
| Map<String, EmailHeader> shallowCopy = new HashMap<>(); |
| shallowCopy.putAll(headers); |
| // Remove To and Cc |
| shallowCopy.remove(FieldName.TO); |
| shallowCopy.remove(FieldName.CC); |
| for (Address a : smtpRcptToPlaintextOnly) { |
| // Add new To |
| AddressList to = new AddressList(); |
| to.add(a); |
| shallowCopy.put(FieldName.TO, to); |
| } |
| if (!validateEmail(va)) return; |
| logger.atFine().log( |
| "Sending plaintext '%s' from %s to %s", |
| messageClass, va.smtpFromAddress, smtpRcptToPlaintextOnly); |
| args.emailSender.send(va.smtpFromAddress, smtpRcptToPlaintextOnly, shallowCopy, va.body); |
| } |
| } |
| } |
| |
| private boolean validateEmail(OutgoingEmailValidationListener.Args va) { |
| for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) { |
| try { |
| validator.validateOutgoingEmail(va); |
| } catch (ValidationException e) { |
| logger.atFine().log( |
| "Not sending '%s': Rejected by outgoing email validator: %s", |
| messageClass, e.getMessage()); |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| // All message ids must start with < and end with >. Also, they must have @domain and no spaces. |
| private void addMessageId(OutgoingEmailValidationListener.Args va, String suffix) { |
| if (messageId != null) { |
| String message = "<" + messageId.id() + suffix + "@" + getGerritHost() + ">"; |
| message = message.replaceAll("\\s", ""); |
| va.headers.put(FieldName.MESSAGE_ID, new StringEmailHeader(message)); |
| } |
| } |
| |
| /** |
| * Setup the message headers and envelope (TO, CC, BCC). |
| * |
| * @throws EmailException if an error occurred. |
| */ |
| public void init() throws EmailException { |
| soyContext = new HashMap<>(); |
| footers = new ArrayList<>(); |
| soyContextEmailData = new HashMap<>(); |
| htmlResources = new ArrayList<>(); |
| |
| smtpFromAddress = args.fromAddressGenerator.get().from(fromId); |
| setHeader(FieldName.DATE, Instant.now()); |
| headers.put(FieldName.FROM, new AddressList(smtpFromAddress)); |
| headers.put(FieldName.TO, new AddressList()); |
| headers.put(FieldName.CC, new AddressList()); |
| setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated"); |
| |
| setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass); |
| addFooter(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass); |
| textBody = new StringBuilder(); |
| htmlBodySections = new ArrayList<>(); |
| |
| if (fromId != null && args.fromAddressGenerator.get().isGenericAddress(fromId)) { |
| appendText(getFromLine()); |
| } |
| |
| templateProvider.init(this); |
| } |
| |
| private String getFromLine() { |
| StringBuilder f = new StringBuilder(); |
| Optional<Account> account = args.accountCache.get(fromId).map(AccountState::account); |
| if (account.isPresent()) { |
| String name = account.get().fullName(); |
| String email = account.get().preferredEmail(); |
| if ((name != null && !name.isEmpty()) || (email != null && !email.isEmpty())) { |
| f.append("From"); |
| if (name != null && !name.isEmpty()) { |
| f.append(" ").append(name); |
| } |
| if (email != null && !email.isEmpty()) { |
| f.append(" <").append(email).append(">"); |
| } |
| f.append(":\n\n"); |
| } |
| } |
| return f.toString(); |
| } |
| |
| public String getGerritHost() { |
| Optional<String> gerritUrl = args.urlFormatter.get().getWebUrl(); |
| if (gerritUrl.isPresent()) { |
| try { |
| return new URL(gerritUrl.get()).getHost(); |
| } catch (MalformedURLException e) { |
| // Try something else. |
| } |
| } |
| |
| // Fall back onto whatever the local operating system thinks |
| // this server is called. We hopefully didn't get here as a |
| // good admin would have configured the canonical url. |
| // |
| return SystemReader.getInstance().getHostname(); |
| } |
| |
| @Nullable |
| public String getSettingsUrl() { |
| return args.urlFormatter.get().getSettingsUrl().map(EmailArguments::addUspParam).orElse(null); |
| } |
| |
| @Nullable |
| public String getSettingsUrl(String section) { |
| return args.urlFormatter |
| .get() |
| .getSettingsUrl(section) |
| .map(EmailArguments::addUspParam) |
| .orElse(null); |
| } |
| |
| /** Set a header in the outgoing message. */ |
| public void setHeader(String name, String value) { |
| headers.put(name, new StringEmailHeader(value)); |
| } |
| |
| /** Remove a header from the outgoing message. */ |
| public void removeHeader(String name) { |
| headers.remove(name); |
| } |
| |
| /** Set a date header in the outgoing message. */ |
| public void setHeader(String name, Instant date) { |
| headers.put(name, new EmailHeader.Date(date)); |
| } |
| |
| /** Append text to the outgoing email body. */ |
| public void appendText(String text) { |
| if (text != null) { |
| textBody.append(text); |
| } |
| } |
| |
| /** Append html to the outgoing email body. */ |
| public void appendHtml(SanitizedContent html) { |
| if (html != null) { |
| htmlBodySections.add(html); |
| } |
| } |
| |
| /** Lookup a human readable name for an account, usually the "full name". */ |
| public String getNameFor(@Nullable Account.Id accountId) { |
| if (accountId == null) { |
| return args.gerritPersonIdent.get().getName(); |
| } |
| |
| Optional<Account> account = args.accountCache.get(accountId).map(AccountState::account); |
| String name = null; |
| if (account.isPresent()) { |
| name = account.get().fullName(); |
| if (name == null) { |
| name = account.get().preferredEmail(); |
| } |
| } |
| if (name == null) { |
| name = args.anonymousCowardName + " #" + accountId; |
| } |
| return name; |
| } |
| |
| /** |
| * Gets the human readable name and email for an account; if neither are available, returns the |
| * Anonymous Coward name. |
| * |
| * @param accountId user to fetch. |
| * @return name/email of account, or Anonymous Coward if unset. |
| */ |
| public String getNameEmailFor(@Nullable Account.Id accountId) { |
| if (accountId == null) { |
| PersonIdent gerritIdent = args.gerritPersonIdent.get(); |
| return gerritIdent.getName() + " <" + gerritIdent.getEmailAddress() + ">"; |
| } |
| |
| Optional<Account> account = args.accountCache.get(accountId).map(AccountState::account); |
| if (account.isPresent()) { |
| String name = account.get().fullName(); |
| String email = account.get().preferredEmail(); |
| if (name != null && email != null) { |
| return name + " <" + email + ">"; |
| } else if (name != null) { |
| return name; |
| } else if (email != null) { |
| return email; |
| } |
| } |
| return args.anonymousCowardName + " #" + accountId; |
| } |
| |
| /** |
| * Gets the human readable name and email for an account; if both are unavailable, returns the |
| * username. If no username is set, this function returns null. |
| * |
| * @param accountId user to fetch. |
| * @return name/email of account, username, or null if unset or the accountId is null. |
| */ |
| @Nullable |
| public String getUserNameEmailFor(@Nullable Account.Id accountId) { |
| if (accountId == null) { |
| return null; |
| } |
| |
| Optional<AccountState> accountState = args.accountCache.get(accountId); |
| if (!accountState.isPresent()) { |
| return null; |
| } |
| |
| Account account = accountState.get().account(); |
| String name = account.fullName(); |
| String email = account.preferredEmail(); |
| if (name != null && email != null) { |
| return name + " <" + email + ">"; |
| } else if (email != null) { |
| return email; |
| } else if (name != null) { |
| return name; |
| } |
| return accountState.get().userName().orElse(null); |
| } |
| |
| private boolean shouldSendMessage() { |
| if (textBody.length() == 0) { |
| // If we have no message body, don't send. |
| logger.atFine().log("Not sending '%s': No message body", messageClass); |
| return false; |
| } |
| |
| if (smtpRcptTo.isEmpty()) { |
| // If we have nobody to send this message to, then all of our |
| // selection filters previously for this type of message were |
| // unable to match a destination. Don't bother sending it. |
| logger.atFine().log("Not sending '%s': No recipients", messageClass); |
| return false; |
| } |
| |
| if (notify.accounts().isEmpty() |
| && smtpRcptTo.size() == 1 |
| && rcptTo.size() == 1 |
| && rcptTo.contains(fromId)) { |
| // If the only recipient is also the sender, don't bother. |
| // |
| logger.atFine().log("Not sending '%s': Sender is only recipient", messageClass); |
| return false; |
| } |
| |
| return templateProvider.shouldSendMessage(); |
| } |
| |
| /** |
| * Adds a recipient that the email will be sent to. |
| * |
| * @param rt category of recipient (TO, CC, BCC) |
| * @param addr Name and email of the recipient. |
| */ |
| public final void addByEmail(RecipientType rt, Address addr) { |
| addByEmail(rt, addr, false); |
| } |
| |
| /** |
| * Adds a recipient that the email will be sent to. |
| * |
| * @param rt category of recipient (TO, CC, BCC). |
| * @param addr Name and email of the recipient. |
| * @param override if the recipient was added previously and override is false no change is made |
| * regardless of {@code rt}. |
| */ |
| public final void addByEmail(RecipientType rt, Address addr, boolean override) { |
| try { |
| if (isRecipientAllowed(addr)) { |
| add(rt, addr, override); |
| } |
| } catch (PermissionBackendException e) { |
| logger.atSevere().withCause(e).log("Error checking permissions for email address: %s", addr); |
| } |
| } |
| |
| /** |
| * Returns whether this email is allowed to be sent to the given address |
| * |
| * @param addr email address of recipient. |
| * @throws PermissionBackendException thrown if checking a permission fails due to an error in the |
| * permission backend |
| */ |
| public boolean isRecipientAllowed(Address addr) throws PermissionBackendException { |
| return templateProvider.isRecipientAllowed(addr); |
| } |
| |
| /** |
| * Adds a recipient that the email will be sent to. |
| * |
| * @param rt category of recipient (TO, CC, BCC) |
| * @param to Gerrit Account of the recipient. |
| */ |
| public void addByAccountId(RecipientType rt, Account.Id to) { |
| addByAccountId(rt, to, false); |
| } |
| |
| /** |
| * Adds a recipient that the email will be sent to. |
| * |
| * @param rt category of recipient (TO, CC, BCC) |
| * @param to Gerrit Account of the recipient. |
| * @param override if the recipient was added previously and override is false no change is made |
| * regardless of {@code rt}. |
| */ |
| public void addByAccountId(RecipientType rt, Account.Id to, boolean override) { |
| try { |
| if (rcptTo.contains(to) || !isRecipientAllowed(to)) { |
| return; |
| } |
| Address addr = toAddress(to); |
| if (addr == null) { |
| logger.atFine().log("Not emailing account %s because user has no preferred email", to); |
| return; |
| } |
| rcptTo.add(to); |
| add(rt, addr, override); |
| } catch (PermissionBackendException e) { |
| logger.atSevere().withCause(e).log("Error checking permissions for account: %s", to); |
| } |
| } |
| |
| /** |
| * Returns whether this email is allowed to be sent to the given account |
| * |
| * @param to account. |
| * @throws PermissionBackendException thrown if checking a permission fails due to an error in the |
| * permission backend |
| */ |
| public boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException { |
| return templateProvider.isRecipientAllowed(to); |
| } |
| |
| private final void add(RecipientType rt, Address addr, boolean override) { |
| if (addr != null && addr.email() != null && addr.email().length() > 0) { |
| if (!args.validator.isValid(addr.email())) { |
| logger.atWarning().log("Not emailing %s (invalid email address)", addr.email()); |
| } else if (args.emailSender.canEmail(addr.email())) { |
| if (!smtpRcptTo.add(addr)) { |
| if (!override) { |
| return; |
| } |
| ((AddressList) headers.get(FieldName.TO)).remove(addr.email()); |
| ((AddressList) headers.get(FieldName.CC)).remove(addr.email()); |
| smtpBccRcptTo.remove(addr); |
| } |
| switch (rt) { |
| case TO: |
| ((AddressList) headers.get(FieldName.TO)).add(addr); |
| break; |
| case CC: |
| ((AddressList) headers.get(FieldName.CC)).add(addr); |
| break; |
| case BCC: |
| smtpBccRcptTo.add(addr); |
| break; |
| } |
| } |
| } |
| } |
| |
| /** Returns preferred email address for the account. */ |
| @Nullable |
| public Address toAddress(Account.Id id) { |
| Optional<Account> accountState = args.accountCache.get(id).map(AccountState::account); |
| if (!accountState.isPresent()) { |
| return null; |
| } |
| |
| Account account = accountState.get(); |
| String e = account.preferredEmail(); |
| if (!account.isActive() || e == null) { |
| return null; |
| } |
| return Address.create(account.fullName(), e); |
| } |
| |
| /** Returns the type of notification being sent. */ |
| public String getMessageClass() { |
| return messageClass; |
| } |
| |
| /** Set recipients, headers, body of the email. */ |
| public void populateEmailContent() throws EmailException { |
| for (RecipientType recipientType : notify.accounts().keySet()) { |
| notify.accounts().get(recipientType).stream().forEach(a -> addByAccountId(recipientType, a)); |
| } |
| |
| addSoyParam("messageClass", messageClass); |
| addSoyParam("footers", footers); |
| addSoyEmailDataParam("settingsUrl", getSettingsUrl()); |
| addSoyEmailDataParam("instanceName", getInstanceName()); |
| addSoyEmailDataParam("gerritHost", getGerritHost()); |
| addSoyParam("email", soyContextEmailData); |
| |
| templateProvider.populateEmailContent(); |
| } |
| |
| /** Adds param to the data map passed into soy when rendering templates. */ |
| public void addSoyParam(String key, Object value) { |
| soyContext.put(key, value); |
| } |
| |
| /** Adds entry to the `email` param passed to the soy when rendering templates. */ |
| public void addSoyEmailDataParam(String key, Object value) { |
| soyContextEmailData.put(key, value); |
| } |
| |
| /** |
| * Add a line to email footer with additional information. Typically, in the form of {@literal |
| * <key>: <value>}. |
| */ |
| public void addFooter(String footer) { |
| footers.add(footer); |
| } |
| |
| /** |
| * Add a resource that can be referenced in HTML code using their {@link EmailResource#contentId}. |
| */ |
| public void addHtmlResource(EmailResource resource) { |
| htmlResources.add(resource); |
| } |
| |
| private String getInstanceName() { |
| return args.instanceNameProvider.get(); |
| } |
| |
| /** Renders a soy template of kind="text". */ |
| public String textTemplate(String name) { |
| return configureRenderer(name).renderText().get(); |
| } |
| |
| /** Renders a soy template of kind="html". */ |
| public SanitizedContent soyHtmlTemplate(String name) { |
| return configureRenderer(name).renderHtml().get(); |
| } |
| |
| /** Renders a soy template of kind="css". */ |
| @UsedAt(UsedAt.Project.GOOGLE) |
| public SanitizedContent soyCssTemplate(String name) { |
| return configureRenderer(name).renderCss().get(); |
| } |
| |
| /** Configures a soy renderer for the given template name and rendering data map. */ |
| private SoySauce.Renderer configureRenderer(String templateName) { |
| int baseNameIndex = templateName.indexOf("_"); |
| // In case there are multiple templates in file (now only InboundEmailRejection and |
| // InboundEmailRejectionHtml). |
| String fileNamespace = |
| baseNameIndex == -1 ? templateName : templateName.substring(0, baseNameIndex); |
| String templateInFileNamespace = |
| String.join(".", SOY_TEMPLATE_NAMESPACE, fileNamespace, templateName); |
| String templateInCommonNamespace = String.join(".", SOY_TEMPLATE_NAMESPACE, templateName); |
| SoySauce soySauce = args.soySauce.get(); |
| // For backwards compatibility with existing customizations and plugin templates with the |
| // old non-unique namespace. |
| String fullTemplateName = |
| soySauce.hasTemplate(templateInFileNamespace) |
| ? templateInFileNamespace |
| : templateInCommonNamespace; |
| return soySauce.renderTemplate(fullTemplateName).setData(soyContext); |
| } |
| |
| /** Remove user from the multipart email recipients. */ |
| private void removeUser(Account user) { |
| String fromEmail = user.preferredEmail(); |
| for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) { |
| if (j.next().email().equals(fromEmail)) { |
| j.remove(); |
| } |
| } |
| for (Map.Entry<String, EmailHeader> entry : headers.entrySet()) { |
| // Don't remove fromEmail from the "From" header though! |
| if (entry.getValue() instanceof AddressList && !entry.getKey().equals("From")) { |
| ((AddressList) entry.getValue()).remove(fromEmail); |
| } |
| } |
| } |
| |
| /** Return true, if the email should include html body. */ |
| public boolean useHtml() { |
| return args.settings.html; |
| } |
| } |