All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.gerrit.server.mail.send.SmtpEmailSender Maven / Gradle / Ivy

There is a newer version: 3.11.0-rc3
Show newest version
// 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. */
@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 allowrcpt;
  private Set 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 rcpt = new HashSet<>();
    Collections.addAll(rcpt, cfg.getStringList("sendemail", null, "allowrcpt"));
    allowrcpt = Collections.unmodifiableSet(rcpt);
    Set 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
rcpt, final Map callerHeaders, String body) throws EmailException { send(from, rcpt, callerHeaders, body, null); } @Override public void send( final Address from, Collection
rcpt, final Map 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 callerHeaders, String textBody, @Nullable String htmlBody) throws IOException, EmailException { final Map 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 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 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); } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy