blob: 17a59a4a0cf6eb97a420639c688cfb6d9cae825d [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.send;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.BaseEncoding;
import com.google.common.primitives.Ints;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.Version;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.EmailHeader;
import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
import com.google.gerrit.exceptions.EmailException;
import com.google.gerrit.server.config.ConfigUtil;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.mail.Encryption;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.AbstractModule;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.Writer;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import org.apache.commons.net.smtp.AuthSMTPClient;
import org.apache.commons.net.smtp.SMTPClient;
import org.apache.commons.net.smtp.SMTPReply;
import org.apache.james.mime4j.codec.QuotedPrintableOutputStream;
import org.eclipse.jgit.lib.Config;
/**
* Sends email via a nearby SMTP server.
*
* <p>Doesn't support including EmailResource in the payload.
*/
@Singleton
public class SmtpEmailSender implements EmailSender {
/** The socket's connect timeout (0 = infinite timeout) */
private static final int DEFAULT_CONNECT_TIMEOUT = 0;
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
public static class SmtpEmailSenderModule extends AbstractModule {
@Override
protected void configure() {
bind(EmailSender.class).to(SmtpEmailSender.class);
}
}
private final boolean enabled;
private final int connectTimeout;
private String smtpHost;
private int smtpPort;
private String smtpUser;
private String smtpPass;
private Encryption smtpEncryption;
private boolean sslVerify;
private Set<String> allowrcpt;
private Set<String> denyrcpt;
private String importance;
private int expiryDays;
@Inject
SmtpEmailSender(@GerritServerConfig Config cfg) {
enabled = cfg.getBoolean("sendemail", null, "enable", true);
connectTimeout =
Ints.checkedCast(
ConfigUtil.getTimeUnit(
cfg,
"sendemail",
null,
"connectTimeout",
DEFAULT_CONNECT_TIMEOUT,
TimeUnit.MILLISECONDS));
smtpHost = cfg.getString("sendemail", null, "smtpserver");
if (smtpHost == null) {
smtpHost = "127.0.0.1";
}
smtpEncryption = cfg.getEnum("sendemail", null, "smtpencryption", Encryption.NONE);
sslVerify = cfg.getBoolean("sendemail", null, "sslverify", true);
final int defaultPort;
switch (smtpEncryption) {
case SSL:
defaultPort = 465;
break;
case NONE:
case TLS:
default:
defaultPort = 25;
break;
}
smtpPort = cfg.getInt("sendemail", null, "smtpserverport", defaultPort);
smtpUser = cfg.getString("sendemail", null, "smtpuser");
smtpPass = cfg.getString("sendemail", null, "smtppass");
Set<String> rcpt = new HashSet<>();
Collections.addAll(rcpt, cfg.getStringList("sendemail", null, "allowrcpt"));
allowrcpt = Collections.unmodifiableSet(rcpt);
Set<String> rcptdeny = new HashSet<>();
Collections.addAll(rcptdeny, cfg.getStringList("sendemail", null, "denyrcpt"));
denyrcpt = Collections.unmodifiableSet(rcptdeny);
importance = cfg.getString("sendemail", null, "importance");
expiryDays = cfg.getInt("sendemail", null, "expiryDays", 0);
}
@Override
public boolean isEnabled() {
return enabled;
}
@Override
public boolean canEmail(String address) {
if (!isEnabled()) {
logger.atWarning().log("Not emailing %s (email is disabled)", address);
return false;
}
String domain = address.substring(address.lastIndexOf('@') + 1);
if (isDenied(address, domain)) {
return false;
}
return isAllowed(address, domain);
}
private boolean isDenied(String address, String domain) {
if (denyrcpt.isEmpty()) {
return false;
}
if (denyrcpt.contains(address)
|| denyrcpt.contains(domain)
|| denyrcpt.contains("@" + domain)) {
logger.atInfo().log("Not emailing %s (prohibited by sendemail.denyrcpt)", address);
return true;
}
return false;
}
private boolean isAllowed(String address, String domain) {
if (allowrcpt.isEmpty()) {
return true;
}
if (allowrcpt.contains(address)
|| allowrcpt.contains(domain)
|| allowrcpt.contains("@" + domain)) {
return true;
}
logger.atWarning().log("Not emailing %s (prohibited by sendemail.allowrcpt)", address);
return false;
}
@Override
public void send(
final Address from,
Collection<Address> rcpt,
final Map<String, EmailHeader> callerHeaders,
String body)
throws EmailException {
send(from, rcpt, callerHeaders, body, null);
}
@Override
public void send(
final Address from,
Collection<Address> rcpt,
final Map<String, EmailHeader> callerHeaders,
String textBody,
@Nullable String htmlBody)
throws EmailException {
if (!isEnabled()) {
throw new EmailException("Sending email is disabled");
}
StringBuilder rejected = new StringBuilder();
try {
final SMTPClient client = open();
try {
if (!client.setSender(from.email())) {
throw new EmailException("Server " + smtpHost + " rejected from address " + from.email());
}
/* Do not prevent the email from being sent to "good" users simply
* because some users get rejected. If not, a single rejected
* project watcher could prevent email for most actions on a project
* from being sent to any user! Instead, queue up the errors, and
* throw an exception after sending the email to get the rejected
* error(s) logged.
*/
for (Address addr : rcpt) {
if (!client.addRecipient(addr.email())) {
String error = client.getReplyString();
rejected
.append("Server ")
.append(smtpHost)
.append(" rejected recipient ")
.append(addr)
.append(": ")
.append(error);
}
}
try (Writer messageDataWriter = client.sendMessageData()) {
if (messageDataWriter == null) {
/* Include rejected recipient error messages here to not lose that
* information. That piece of the puzzle is vital if zero recipients
* are accepted and the server consequently rejects the DATA command.
*/
throw new EmailException(
rejected
.append("Server ")
.append(smtpHost)
.append(" rejected DATA command: ")
.append(client.getReplyString())
.toString());
}
render(messageDataWriter, callerHeaders, textBody, htmlBody);
if (!client.completePendingCommand()) {
throw new EmailException(
"Server " + smtpHost + " rejected message body: " + client.getReplyString());
}
client.logout();
if (rejected.length() > 0) {
throw new EmailException(rejected.toString());
}
}
} finally {
client.disconnect();
}
} catch (IOException e) {
throw new EmailException("Cannot send outgoing email", e);
}
}
private void render(
Writer out,
Map<String, EmailHeader> callerHeaders,
String textBody,
@Nullable String htmlBody)
throws IOException, EmailException {
final Map<String, EmailHeader> hdrs = new LinkedHashMap<>(callerHeaders);
setMissingHeader(hdrs, "MIME-Version", "1.0");
setMissingHeader(hdrs, "Content-Transfer-Encoding", "8bit");
setMissingHeader(hdrs, "Content-Disposition", "inline");
setMissingHeader(hdrs, "User-Agent", "Gerrit/" + Version.getVersion());
if (importance != null) {
setMissingHeader(hdrs, "Importance", importance);
}
if (expiryDays > 0) {
Instant expiry = Instant.ofEpochMilli(TimeUtil.nowMs() + expiryDays * 24 * 60 * 60 * 1000L);
DateTimeFormatter fmt =
DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z")
.withZone(ZoneId.systemDefault());
setMissingHeader(hdrs, "Expiry-Date", fmt.format(expiry));
}
String encodedBody;
if (htmlBody == null) {
setMissingHeader(hdrs, "Content-Type", "text/plain; charset=UTF-8");
encodedBody = textBody;
} else {
String boundary = generateMultipartBoundary(textBody, htmlBody);
setMissingHeader(
hdrs,
"Content-Type",
"multipart/alternative; boundary=\"" + boundary + "\"; charset=UTF-8");
encodedBody = buildMultipartBody(boundary, textBody, htmlBody);
}
try (Writer w = new BufferedWriter(out)) {
for (Map.Entry<String, EmailHeader> h : hdrs.entrySet()) {
if (!h.getValue().isEmpty()) {
w.write(h.getKey());
w.write(": ");
h.getValue().write(w);
w.write("\r\n");
}
}
w.write("\r\n");
w.write(encodedBody);
w.flush();
}
}
public static String generateMultipartBoundary(String textBody, String htmlBody)
throws EmailException {
byte[] bytes = new byte[8];
ThreadLocalRandom rng = ThreadLocalRandom.current();
// The probability of the boundary being valid is approximately
// (2^64 - len(message)) / 2^64.
//
// The message is much shorter than 2^64 bytes, so if two tries don't
// suffice, something is seriously wrong.
for (int i = 0; i < 2; i++) {
rng.nextBytes(bytes);
String boundary = BaseEncoding.base64().encode(bytes);
String encBoundary = "--" + boundary;
if (textBody.contains(encBoundary) || htmlBody.contains(encBoundary)) {
continue;
}
return boundary;
}
throw new EmailException("Gave up generating unique MIME boundary");
}
protected String buildMultipartBody(String boundary, String textPart, String htmlPart)
throws IOException {
String encodedTextPart = quotedPrintableEncode(textPart);
String encodedHtmlPart = quotedPrintableEncode(htmlPart);
// Only declare quoted-printable encoding if there are characters that need to be encoded.
String textTransferEncoding = textPart.equals(encodedTextPart) ? "7bit" : "quoted-printable";
String htmlTransferEncoding = htmlPart.equals(encodedHtmlPart) ? "7bit" : "quoted-printable";
return
// Output the text part:
"--"
+ boundary
+ "\r\n"
+ "Content-Type: text/plain; charset=UTF-8\r\n"
+ "Content-Transfer-Encoding: "
+ textTransferEncoding
+ "\r\n"
+ "\r\n"
+ encodedTextPart
+ "\r\n"
// Output the HTML part:
+ "--"
+ boundary
+ "\r\n"
+ "Content-Type: text/html; charset=UTF-8\r\n"
+ "Content-Transfer-Encoding: "
+ htmlTransferEncoding
+ "\r\n"
+ "\r\n"
+ encodedHtmlPart
+ "\r\n"
// Output the closing boundary.
+ "--"
+ boundary
+ "--\r\n";
}
protected String quotedPrintableEncode(String input) throws IOException {
ByteArrayOutputStream s = new ByteArrayOutputStream();
try (QuotedPrintableOutputStream qp = new QuotedPrintableOutputStream(s, false)) {
qp.write(input.getBytes(UTF_8));
}
return s.toString(UTF_8);
}
private static void setMissingHeader(Map<String, EmailHeader> hdrs, String name, String value) {
if (!hdrs.containsKey(name) || hdrs.get(name).isEmpty()) {
hdrs.put(name, new StringEmailHeader(value));
}
}
private SMTPClient open() throws EmailException {
final AuthSMTPClient client = new AuthSMTPClient(smtpEncryption == Encryption.SSL, sslVerify);
client.setConnectTimeout(connectTimeout);
try {
client.connect(smtpHost, smtpPort);
int replyCode = client.getReplyCode();
String replyString = client.getReplyString();
if (!SMTPReply.isPositiveCompletion(replyCode)) {
throw new EmailException(
String.format("SMTP server rejected connection: %d: %s", replyCode, replyString));
}
if (!client.login()) {
throw new EmailException("SMTP server rejected HELO/EHLO greeting: " + replyString);
}
if (smtpEncryption == Encryption.TLS) {
if (!client.execTLS()) {
throw new EmailException("SMTP server does not support TLS");
}
if (!client.login()) {
throw new EmailException("SMTP server rejected login: " + replyString);
}
}
if (smtpUser != null && !client.auth(smtpUser, smtpPass)) {
throw new EmailException("SMTP server rejected auth: " + replyString);
}
return client;
} catch (IOException | EmailException e) {
if (client.isConnected()) {
try {
client.disconnect();
} catch (IOException e2) {
// Ignored
}
}
if (e instanceof EmailException) {
throw (EmailException) e;
}
throw new EmailException(e.getMessage(), e);
}
}
}