| // 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.atWarning().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); | |
| } | |
| } | |
| } |