blob: 8a288e7a78e452f97d203547283cfe595a5c12ac [file] [log] [blame]
// 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.mail;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.collect.Sets;
import com.google.gerrit.common.errors.EmailException;
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.mail.EmailHeader.AddressList;
import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
import com.google.gerrit.server.validators.ValidationException;
import com.google.gwtorm.server.OrmException;
import org.apache.commons.lang.StringUtils;
import org.apache.velocity.Template;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.context.InternalContextAdapterImpl;
import org.apache.velocity.runtime.RuntimeInstance;
import org.apache.velocity.runtime.parser.node.SimpleNode;
import org.eclipse.jgit.util.SystemReader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.StringReader;
import java.io.StringWriter;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collection;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/** Sends an email to one or more interested parties. */
public abstract class OutgoingEmail {
private static final Logger log = LoggerFactory.getLogger(OutgoingEmail.class);
private static final String HDR_TO = "To";
private static final String HDR_CC = "CC";
protected String messageClass;
private final HashSet<Account.Id> rcptTo = new HashSet<>();
private final Map<String, EmailHeader> headers;
private final Set<Address> smtpRcptTo = Sets.newHashSet();
private Address smtpFromAddress;
private StringBuilder body;
protected VelocityContext velocityContext;
protected final EmailArguments args;
protected Account.Id fromId;
protected OutgoingEmail(EmailArguments ea, String mc) {
args = ea;
messageClass = mc;
headers = new LinkedHashMap<>();
}
public void setFrom(final Account.Id id) {
fromId = id;
}
/**
* Format and enqueue the message for delivery.
*
* @throws EmailException
*/
public void send() throws EmailException {
if (!args.emailSender.isEnabled()) {
// Server has explicitly disabled email sending.
//
return;
}
init();
format();
appendText(velocifyFile("Footer.vm"));
if (shouldSendMessage()) {
if (fromId != null) {
final Account fromUser = args.accountCache.get(fromId).getAccount();
if (fromUser.getGeneralPreferences().isCopySelfOnEmails()) {
// 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 (rcptTo.remove(fromId)) {
// If they don't want a copy, but we queued one up anyway,
// drop them from the recipient lists.
//
final String fromEmail = fromUser.getPreferredEmail();
for (Iterator<Address> i = smtpRcptTo.iterator(); i.hasNext();) {
if (i.next().email.equals(fromEmail)) {
i.remove();
}
}
for (EmailHeader hdr : headers.values()) {
if (hdr instanceof AddressList) {
((AddressList) hdr).remove(fromEmail);
}
}
if (smtpRcptTo.isEmpty()) {
return;
}
}
}
OutgoingEmailValidationListener.Args va = new OutgoingEmailValidationListener.Args();
va.messageClass = messageClass;
va.smtpFromAddress = smtpFromAddress;
va.smtpRcptTo = smtpRcptTo;
va.headers = headers;
va.body = body.toString();
for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
try {
validator.validateOutgoingEmail(va);
} catch (ValidationException e) {
return;
}
}
args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers, 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 {
setupVelocityContext();
smtpFromAddress = args.fromAddressGenerator.from(fromId);
setHeader("Date", new Date());
headers.put("From", new EmailHeader.AddressList(smtpFromAddress));
headers.put(HDR_TO, new EmailHeader.AddressList());
headers.put(HDR_CC, new EmailHeader.AddressList());
setHeader("Message-ID", "");
if (fromId != null) {
// If we have a user that this message is supposedly caused by
// but the From header on the email does not match the user as
// it is a generic header for this Gerrit server, include the
// Reply-To header with the current user's email address.
//
final Address a = toAddress(fromId);
if (a != null && !smtpFromAddress.email.equals(a.email)) {
setHeader("Reply-To", a.email);
}
}
setHeader("X-Gerrit-MessageType", messageClass);
body = new StringBuilder();
if (fromId != null && args.fromAddressGenerator.isGenericAddress(fromId)) {
appendText(getFromLine());
}
}
protected String getFromLine() {
final Account account = args.accountCache.get(fromId).getAccount();
final String name = account.getFullName();
final String email = account.getPreferredEmail();
StringBuilder f = new StringBuilder();
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() {
if (getGerritUrl() != null) {
final StringBuilder r = new StringBuilder();
r.append(getGerritUrl());
r.append("settings");
return r.toString();
}
return null;
}
public String getGerritUrl() {
return args.urlProvider.get();
}
/** Set a header in the outgoing message using a template. */
protected void setVHeader(final String name, final String value) throws
EmailException {
setHeader(name, velocify(value));
}
/** Set a header in the outgoing message. */
protected void setHeader(final String name, final String value) {
headers.put(name, new EmailHeader.String(value));
}
protected void setHeader(final String name, final Date date) {
headers.put(name, new EmailHeader.Date(date));
}
/** Append text to the outgoing email body. */
protected void appendText(final String text) {
if (text != null) {
body.append(text);
}
}
/** Lookup a human readable name for an account, usually the "full name". */
protected String getNameFor(final Account.Id accountId) {
if (accountId == null) {
return args.gerritPersonIdent.getName();
}
final Account userAccount = args.accountCache.get(accountId).getAccount();
String name = userAccount.getFullName();
if (name == null) {
name = userAccount.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.
*/
public String getNameEmailFor(Account.Id accountId) {
AccountState who = args.accountCache.get(accountId);
String name = who.getAccount().getFullName();
String email = who.getAccount().getPreferredEmail();
if (name != null && email != null) {
return name + " <" + email + ">";
} else if (name != null) {
return name;
} else if (email != null) {
return email;
} else /* (name == null && email == null) */{
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.
*/
public String getUserNameEmailFor(Account.Id accountId) {
AccountState who = args.accountCache.get(accountId);
String name = who.getAccount().getFullName();
String email = who.getAccount().getPreferredEmail();
if (name != null && email != null) {
return name + " <" + email + ">";
} else if (email != null) {
return email;
} else if (name != null) {
return name;
}
String username = who.getUserName();
if (username != null) {
return username;
}
return null;
}
protected boolean shouldSendMessage() {
if (body.length() == 0) {
// If we have no message body, don't send.
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.
return false;
}
if (smtpRcptTo.size() == 1 && rcptTo.size() == 1 && rcptTo.contains(fromId)) {
// If the only recipient is also the sender, don't bother.
//
return false;
}
return true;
}
/** Schedule this message for delivery to the listed accounts. */
protected void add(final RecipientType rt, final Collection<Account.Id> list) {
for (final Account.Id id : list) {
add(rt, id);
}
}
protected void add(final RecipientType rt, final UserIdentity who) {
if (who != null && who.getAccount() != null) {
add(rt, who.getAccount());
}
}
/** Schedule delivery of this message to the given account. */
protected void add(final RecipientType rt, final Account.Id to) {
try {
if (!rcptTo.contains(to) && isVisibleTo(to)) {
rcptTo.add(to);
add(rt, toAddress(to));
}
} catch (OrmException e) {
log.error("Error reading database for account: " + to, e);
}
}
/**
* @param to account.
* @throws OrmException
* @return whether this email is visible to the given account.
*/
protected boolean isVisibleTo(final Account.Id to) throws OrmException {
return true;
}
/** Schedule delivery of this message to the given account. */
protected void add(final RecipientType rt, final Address addr) {
if (addr != null && addr.email != null && addr.email.length() > 0) {
if (!OutgoingEmailValidator.isValid(addr.email)) {
log.warn("Not emailing " + addr.email + " (invalid email address)");
} else if (!args.emailSender.canEmail(addr.email)) {
log.warn("Not emailing " + addr.email + " (prohibited by allowrcpt)");
} else if (smtpRcptTo.add(addr)) {
switch (rt) {
case TO:
((EmailHeader.AddressList) headers.get(HDR_TO)).add(addr);
break;
case CC:
((EmailHeader.AddressList) headers.get(HDR_CC)).add(addr);
break;
case BCC:
break;
}
}
}
}
private Address toAddress(final Account.Id id) {
final Account a = args.accountCache.get(id).getAccount();
final String e = a.getPreferredEmail();
if (!a.isActive() || e == null) {
return null;
}
return new Address(a.getFullName(), e);
}
protected void setupVelocityContext() {
velocityContext = new VelocityContext();
velocityContext.put("email", this);
velocityContext.put("messageClass", messageClass);
velocityContext.put("StringUtils", StringUtils.class);
}
protected String velocify(String template) throws EmailException {
try {
RuntimeInstance runtime = args.velocityRuntime;
String templateName = "OutgoingEmail";
SimpleNode tree = runtime.parse(new StringReader(template), templateName);
InternalContextAdapterImpl ica = new InternalContextAdapterImpl(velocityContext);
ica.pushCurrentTemplateName(templateName);
try {
tree.init(ica, runtime);
StringWriter w = new StringWriter();
tree.render(ica, w);
return w.toString();
} finally {
ica.popCurrentTemplateName();
}
} catch (Exception e) {
throw new EmailException("Cannot format velocity template: " + template, e);
}
}
protected String velocifyFile(String name) throws EmailException {
try {
RuntimeInstance runtime = args.velocityRuntime;
if (runtime.getLoaderNameForResource(name) == null) {
name = "com/google/gerrit/server/mail/" + name;
}
Template template = runtime.getTemplate(name, UTF_8.name());
StringWriter w = new StringWriter();
template.merge(velocityContext, w);
return w.toString();
} catch (Exception e) {
throw new EmailException("Cannot format velocity template " + name, e);
}
}
public String joinStrings(Iterable<Object> in, String joiner) {
return joinStrings(in.iterator(), joiner);
}
public String joinStrings(Iterator<Object> in, String joiner) {
if (!in.hasNext()) {
return "";
}
Object first = in.next();
if (!in.hasNext()) {
return safeToString(first);
}
StringBuilder r = new StringBuilder();
r.append(safeToString(first));
while (in.hasNext()) {
r.append(joiner).append(safeToString(in.next()));
}
return r.toString();
}
private static String safeToString(Object obj) {
return obj != null ? obj.toString() : "";
}
}