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 : }
|