LCOV - code coverage report
Current view: top level - server/mail/send - SmtpEmailSender.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 0 172 0.0 %
Date: 2022-11-19 15:00:39 Functions: 0 16 0.0 %

          Line data    Source code
       1             : // Copyright (C) 2009 The Android Open Source Project
       2             : //
       3             : // Licensed under the Apache License, Version 2.0 (the "License");
       4             : // you may not use this file except in compliance with the License.
       5             : // You may obtain a copy of the License at
       6             : //
       7             : // http://www.apache.org/licenses/LICENSE-2.0
       8             : //
       9             : // Unless required by applicable law or agreed to in writing, software
      10             : // distributed under the License is distributed on an "AS IS" BASIS,
      11             : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12             : // See the License for the specific language governing permissions and
      13             : // limitations under the License.
      14             : 
      15             : package com.google.gerrit.server.mail.send;
      16             : 
      17             : import static java.nio.charset.StandardCharsets.UTF_8;
      18             : 
      19             : import com.google.common.flogger.FluentLogger;
      20             : import com.google.common.io.BaseEncoding;
      21             : import com.google.common.primitives.Ints;
      22             : import com.google.gerrit.common.Nullable;
      23             : import com.google.gerrit.common.Version;
      24             : import com.google.gerrit.entities.Address;
      25             : import com.google.gerrit.entities.EmailHeader;
      26             : import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
      27             : import com.google.gerrit.exceptions.EmailException;
      28             : import com.google.gerrit.server.config.ConfigUtil;
      29             : import com.google.gerrit.server.config.GerritServerConfig;
      30             : import com.google.gerrit.server.mail.Encryption;
      31             : import com.google.gerrit.server.util.time.TimeUtil;
      32             : import com.google.inject.AbstractModule;
      33             : import com.google.inject.Inject;
      34             : import com.google.inject.Singleton;
      35             : import java.io.BufferedWriter;
      36             : import java.io.ByteArrayOutputStream;
      37             : import java.io.IOException;
      38             : import java.io.Writer;
      39             : import java.time.Instant;
      40             : import java.time.ZoneId;
      41             : import java.time.format.DateTimeFormatter;
      42             : import java.util.Collection;
      43             : import java.util.Collections;
      44             : import java.util.HashSet;
      45             : import java.util.LinkedHashMap;
      46             : import java.util.Map;
      47             : import java.util.Set;
      48             : import java.util.concurrent.ThreadLocalRandom;
      49             : import java.util.concurrent.TimeUnit;
      50             : import org.apache.commons.net.smtp.AuthSMTPClient;
      51             : import org.apache.commons.net.smtp.SMTPClient;
      52             : import org.apache.commons.net.smtp.SMTPReply;
      53             : import org.apache.james.mime4j.codec.QuotedPrintableOutputStream;
      54             : import org.eclipse.jgit.lib.Config;
      55             : 
      56             : /** Sends email via a nearby SMTP server. */
      57             : @Singleton
      58             : public class SmtpEmailSender implements EmailSender {
      59             :   /** The socket's connect timeout (0 = infinite timeout) */
      60             :   private static final int DEFAULT_CONNECT_TIMEOUT = 0;
      61             : 
      62           0 :   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
      63             : 
      64           0 :   public static class SmtpEmailSenderModule extends AbstractModule {
      65             :     @Override
      66             :     protected void configure() {
      67           0 :       bind(EmailSender.class).to(SmtpEmailSender.class);
      68           0 :     }
      69             :   }
      70             : 
      71             :   private final boolean enabled;
      72             :   private final int connectTimeout;
      73             : 
      74             :   private String smtpHost;
      75             :   private int smtpPort;
      76             :   private String smtpUser;
      77             :   private String smtpPass;
      78             :   private Encryption smtpEncryption;
      79             :   private boolean sslVerify;
      80             :   private Set<String> allowrcpt;
      81             :   private Set<String> denyrcpt;
      82             :   private String importance;
      83             :   private int expiryDays;
      84             : 
      85             :   @Inject
      86           0 :   SmtpEmailSender(@GerritServerConfig Config cfg) {
      87           0 :     enabled = cfg.getBoolean("sendemail", null, "enable", true);
      88           0 :     connectTimeout =
      89           0 :         Ints.checkedCast(
      90           0 :             ConfigUtil.getTimeUnit(
      91             :                 cfg,
      92             :                 "sendemail",
      93             :                 null,
      94             :                 "connectTimeout",
      95             :                 DEFAULT_CONNECT_TIMEOUT,
      96             :                 TimeUnit.MILLISECONDS));
      97             : 
      98           0 :     smtpHost = cfg.getString("sendemail", null, "smtpserver");
      99           0 :     if (smtpHost == null) {
     100           0 :       smtpHost = "127.0.0.1";
     101             :     }
     102             : 
     103           0 :     smtpEncryption = cfg.getEnum("sendemail", null, "smtpencryption", Encryption.NONE);
     104           0 :     sslVerify = cfg.getBoolean("sendemail", null, "sslverify", true);
     105             : 
     106             :     final int defaultPort;
     107           0 :     switch (smtpEncryption) {
     108             :       case SSL:
     109           0 :         defaultPort = 465;
     110           0 :         break;
     111             : 
     112             :       case NONE:
     113             :       case TLS:
     114             :       default:
     115           0 :         defaultPort = 25;
     116             :         break;
     117             :     }
     118           0 :     smtpPort = cfg.getInt("sendemail", null, "smtpserverport", defaultPort);
     119             : 
     120           0 :     smtpUser = cfg.getString("sendemail", null, "smtpuser");
     121           0 :     smtpPass = cfg.getString("sendemail", null, "smtppass");
     122             : 
     123           0 :     Set<String> rcpt = new HashSet<>();
     124           0 :     Collections.addAll(rcpt, cfg.getStringList("sendemail", null, "allowrcpt"));
     125           0 :     allowrcpt = Collections.unmodifiableSet(rcpt);
     126           0 :     Set<String> rcptdeny = new HashSet<>();
     127           0 :     Collections.addAll(rcptdeny, cfg.getStringList("sendemail", null, "denyrcpt"));
     128           0 :     denyrcpt = Collections.unmodifiableSet(rcptdeny);
     129           0 :     importance = cfg.getString("sendemail", null, "importance");
     130           0 :     expiryDays = cfg.getInt("sendemail", null, "expiryDays", 0);
     131           0 :   }
     132             : 
     133             :   @Override
     134             :   public boolean isEnabled() {
     135           0 :     return enabled;
     136             :   }
     137             : 
     138             :   @Override
     139             :   public boolean canEmail(String address) {
     140           0 :     if (!isEnabled()) {
     141           0 :       logger.atWarning().log("Not emailing %s (email is disabled)", address);
     142           0 :       return false;
     143             :     }
     144             : 
     145           0 :     String domain = address.substring(address.lastIndexOf('@') + 1);
     146           0 :     if (isDenied(address, domain)) {
     147           0 :       return false;
     148             :     }
     149             : 
     150           0 :     return isAllowed(address, domain);
     151             :   }
     152             : 
     153             :   private boolean isDenied(String address, String domain) {
     154             : 
     155           0 :     if (denyrcpt.isEmpty()) {
     156           0 :       return false;
     157             :     }
     158             : 
     159           0 :     if (denyrcpt.contains(address)
     160           0 :         || denyrcpt.contains(domain)
     161           0 :         || denyrcpt.contains("@" + domain)) {
     162           0 :       logger.atWarning().log("Not emailing %s (prohibited by sendemail.denyrcpt)", address);
     163           0 :       return true;
     164             :     }
     165             : 
     166           0 :     return false;
     167             :   }
     168             : 
     169             :   private boolean isAllowed(String address, String domain) {
     170             : 
     171           0 :     if (allowrcpt.isEmpty()) {
     172           0 :       return true;
     173             :     }
     174             : 
     175           0 :     if (allowrcpt.contains(address)
     176           0 :         || allowrcpt.contains(domain)
     177           0 :         || allowrcpt.contains("@" + domain)) {
     178           0 :       return true;
     179             :     }
     180             : 
     181           0 :     logger.atWarning().log("Not emailing %s (prohibited by sendemail.allowrcpt)", address);
     182           0 :     return false;
     183             :   }
     184             : 
     185             :   @Override
     186             :   public void send(
     187             :       final Address from,
     188             :       Collection<Address> rcpt,
     189             :       final Map<String, EmailHeader> callerHeaders,
     190             :       String body)
     191             :       throws EmailException {
     192           0 :     send(from, rcpt, callerHeaders, body, null);
     193           0 :   }
     194             : 
     195             :   @Override
     196             :   public void send(
     197             :       final Address from,
     198             :       Collection<Address> rcpt,
     199             :       final Map<String, EmailHeader> callerHeaders,
     200             :       String textBody,
     201             :       @Nullable String htmlBody)
     202             :       throws EmailException {
     203           0 :     if (!isEnabled()) {
     204           0 :       throw new EmailException("Sending email is disabled");
     205             :     }
     206             : 
     207           0 :     StringBuilder rejected = new StringBuilder();
     208             :     try {
     209           0 :       final SMTPClient client = open();
     210             :       try {
     211           0 :         if (!client.setSender(from.email())) {
     212           0 :           throw new EmailException("Server " + smtpHost + " rejected from address " + from.email());
     213             :         }
     214             : 
     215             :         /* Do not prevent the email from being sent to "good" users simply
     216             :          * because some users get rejected.  If not, a single rejected
     217             :          * project watcher could prevent email for most actions on a project
     218             :          * from being sent to any user!  Instead, queue up the errors, and
     219             :          * throw an exception after sending the email to get the rejected
     220             :          * error(s) logged.
     221             :          */
     222           0 :         for (Address addr : rcpt) {
     223           0 :           if (!client.addRecipient(addr.email())) {
     224           0 :             String error = client.getReplyString();
     225           0 :             rejected
     226           0 :                 .append("Server ")
     227           0 :                 .append(smtpHost)
     228           0 :                 .append(" rejected recipient ")
     229           0 :                 .append(addr)
     230           0 :                 .append(": ")
     231           0 :                 .append(error);
     232             :           }
     233           0 :         }
     234             : 
     235           0 :         try (Writer messageDataWriter = client.sendMessageData()) {
     236           0 :           if (messageDataWriter == null) {
     237             :             /* Include rejected recipient error messages here to not lose that
     238             :              * information. That piece of the puzzle is vital if zero recipients
     239             :              * are accepted and the server consequently rejects the DATA command.
     240             :              */
     241           0 :             throw new EmailException(
     242             :                 rejected
     243           0 :                     .append("Server ")
     244           0 :                     .append(smtpHost)
     245           0 :                     .append(" rejected DATA command: ")
     246           0 :                     .append(client.getReplyString())
     247           0 :                     .toString());
     248             :           }
     249             : 
     250           0 :           render(messageDataWriter, callerHeaders, textBody, htmlBody);
     251             : 
     252           0 :           if (!client.completePendingCommand()) {
     253           0 :             throw new EmailException(
     254           0 :                 "Server " + smtpHost + " rejected message body: " + client.getReplyString());
     255             :           }
     256             : 
     257           0 :           client.logout();
     258           0 :           if (rejected.length() > 0) {
     259           0 :             throw new EmailException(rejected.toString());
     260             :           }
     261             :         }
     262             :       } finally {
     263           0 :         client.disconnect();
     264             :       }
     265           0 :     } catch (IOException e) {
     266           0 :       throw new EmailException("Cannot send outgoing email", e);
     267           0 :     }
     268           0 :   }
     269             : 
     270             :   private void render(
     271             :       Writer out,
     272             :       Map<String, EmailHeader> callerHeaders,
     273             :       String textBody,
     274             :       @Nullable String htmlBody)
     275             :       throws IOException, EmailException {
     276           0 :     final Map<String, EmailHeader> hdrs = new LinkedHashMap<>(callerHeaders);
     277           0 :     setMissingHeader(hdrs, "MIME-Version", "1.0");
     278           0 :     setMissingHeader(hdrs, "Content-Transfer-Encoding", "8bit");
     279           0 :     setMissingHeader(hdrs, "Content-Disposition", "inline");
     280           0 :     setMissingHeader(hdrs, "User-Agent", "Gerrit/" + Version.getVersion());
     281           0 :     if (importance != null) {
     282           0 :       setMissingHeader(hdrs, "Importance", importance);
     283             :     }
     284           0 :     if (expiryDays > 0) {
     285           0 :       Instant expiry = Instant.ofEpochMilli(TimeUtil.nowMs() + expiryDays * 24 * 60 * 60 * 1000L);
     286           0 :       DateTimeFormatter fmt =
     287           0 :           DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z")
     288           0 :               .withZone(ZoneId.systemDefault());
     289           0 :       setMissingHeader(hdrs, "Expiry-Date", fmt.format(expiry));
     290             :     }
     291             : 
     292             :     String encodedBody;
     293           0 :     if (htmlBody == null) {
     294           0 :       setMissingHeader(hdrs, "Content-Type", "text/plain; charset=UTF-8");
     295           0 :       encodedBody = textBody;
     296             :     } else {
     297           0 :       String boundary = generateMultipartBoundary(textBody, htmlBody);
     298           0 :       setMissingHeader(
     299             :           hdrs,
     300             :           "Content-Type",
     301             :           "multipart/alternative; boundary=\"" + boundary + "\"; charset=UTF-8");
     302           0 :       encodedBody = buildMultipartBody(boundary, textBody, htmlBody);
     303             :     }
     304             : 
     305           0 :     try (Writer w = new BufferedWriter(out)) {
     306           0 :       for (Map.Entry<String, EmailHeader> h : hdrs.entrySet()) {
     307           0 :         if (!h.getValue().isEmpty()) {
     308           0 :           w.write(h.getKey());
     309           0 :           w.write(": ");
     310           0 :           h.getValue().write(w);
     311           0 :           w.write("\r\n");
     312             :         }
     313           0 :       }
     314             : 
     315           0 :       w.write("\r\n");
     316           0 :       w.write(encodedBody);
     317           0 :       w.flush();
     318             :     }
     319           0 :   }
     320             : 
     321             :   public static String generateMultipartBoundary(String textBody, String htmlBody)
     322             :       throws EmailException {
     323           0 :     byte[] bytes = new byte[8];
     324           0 :     ThreadLocalRandom rng = ThreadLocalRandom.current();
     325             : 
     326             :     // The probability of the boundary being valid is approximately
     327             :     // (2^64 - len(message)) / 2^64.
     328             :     //
     329             :     // The message is much shorter than 2^64 bytes, so if two tries don't
     330             :     // suffice, something is seriously wrong.
     331           0 :     for (int i = 0; i < 2; i++) {
     332           0 :       rng.nextBytes(bytes);
     333           0 :       String boundary = BaseEncoding.base64().encode(bytes);
     334           0 :       String encBoundary = "--" + boundary;
     335           0 :       if (textBody.contains(encBoundary) || htmlBody.contains(encBoundary)) {
     336           0 :         continue;
     337             :       }
     338           0 :       return boundary;
     339             :     }
     340           0 :     throw new EmailException("Gave up generating unique MIME boundary");
     341             :   }
     342             : 
     343             :   protected String buildMultipartBody(String boundary, String textPart, String htmlPart)
     344             :       throws IOException {
     345           0 :     String encodedTextPart = quotedPrintableEncode(textPart);
     346           0 :     String encodedHtmlPart = quotedPrintableEncode(htmlPart);
     347             : 
     348             :     // Only declare quoted-printable encoding if there are characters that need to be encoded.
     349           0 :     String textTransferEncoding = textPart.equals(encodedTextPart) ? "7bit" : "quoted-printable";
     350           0 :     String htmlTransferEncoding = htmlPart.equals(encodedHtmlPart) ? "7bit" : "quoted-printable";
     351             : 
     352           0 :     return
     353             :     // Output the text part:
     354             :     "--"
     355             :         + boundary
     356             :         + "\r\n"
     357             :         + "Content-Type: text/plain; charset=UTF-8\r\n"
     358             :         + "Content-Transfer-Encoding: "
     359             :         + textTransferEncoding
     360             :         + "\r\n"
     361             :         + "\r\n"
     362             :         + encodedTextPart
     363             :         + "\r\n"
     364             : 
     365             :         // Output the HTML part:
     366             :         + "--"
     367             :         + boundary
     368             :         + "\r\n"
     369             :         + "Content-Type: text/html; charset=UTF-8\r\n"
     370             :         + "Content-Transfer-Encoding: "
     371             :         + htmlTransferEncoding
     372             :         + "\r\n"
     373             :         + "\r\n"
     374             :         + encodedHtmlPart
     375             :         + "\r\n"
     376             : 
     377             :         // Output the closing boundary.
     378             :         + "--"
     379             :         + boundary
     380             :         + "--\r\n";
     381             :   }
     382             : 
     383             :   protected String quotedPrintableEncode(String input) throws IOException {
     384           0 :     ByteArrayOutputStream s = new ByteArrayOutputStream();
     385           0 :     try (QuotedPrintableOutputStream qp = new QuotedPrintableOutputStream(s, false)) {
     386           0 :       qp.write(input.getBytes(UTF_8));
     387             :     }
     388           0 :     return s.toString(UTF_8);
     389             :   }
     390             : 
     391             :   private static void setMissingHeader(Map<String, EmailHeader> hdrs, String name, String value) {
     392           0 :     if (!hdrs.containsKey(name) || hdrs.get(name).isEmpty()) {
     393           0 :       hdrs.put(name, new StringEmailHeader(value));
     394             :     }
     395           0 :   }
     396             : 
     397             :   private SMTPClient open() throws EmailException {
     398           0 :     final AuthSMTPClient client = new AuthSMTPClient(smtpEncryption == Encryption.SSL, sslVerify);
     399             : 
     400           0 :     client.setConnectTimeout(connectTimeout);
     401             :     try {
     402           0 :       client.connect(smtpHost, smtpPort);
     403           0 :       int replyCode = client.getReplyCode();
     404           0 :       String replyString = client.getReplyString();
     405           0 :       if (!SMTPReply.isPositiveCompletion(replyCode)) {
     406           0 :         throw new EmailException(
     407           0 :             String.format("SMTP server rejected connection: %d: %s", replyCode, replyString));
     408             :       }
     409           0 :       if (!client.login()) {
     410           0 :         throw new EmailException("SMTP server rejected HELO/EHLO greeting: " + replyString);
     411             :       }
     412             : 
     413           0 :       if (smtpEncryption == Encryption.TLS) {
     414           0 :         if (!client.execTLS()) {
     415           0 :           throw new EmailException("SMTP server does not support TLS");
     416             :         }
     417           0 :         if (!client.login()) {
     418           0 :           throw new EmailException("SMTP server rejected login: " + replyString);
     419             :         }
     420             :       }
     421             : 
     422           0 :       if (smtpUser != null && !client.auth(smtpUser, smtpPass)) {
     423           0 :         throw new EmailException("SMTP server rejected auth: " + replyString);
     424             :       }
     425           0 :       return client;
     426           0 :     } catch (IOException | EmailException e) {
     427           0 :       if (client.isConnected()) {
     428             :         try {
     429           0 :           client.disconnect();
     430           0 :         } catch (IOException e2) {
     431             :           // Ignored
     432           0 :         }
     433             :       }
     434           0 :       if (e instanceof EmailException) {
     435           0 :         throw (EmailException) e;
     436             :       }
     437           0 :       throw new EmailException(e.getMessage(), e);
     438             :     }
     439             :   }
     440             : }

Generated by: LCOV version 1.16+git.20220603.dfeb750