]> source.dussan.org Git - sonarqube.git/blob
b052d3110a58d89409173641ca13cfd26bd28903
[sonarqube.git] /
1 /*
2  * SonarQube
3  * Copyright (C) 2009-2024 SonarSource SA
4  * mailto:info AT sonarsource DOT com
5  *
6  * This program is free software; you can redistribute it and/or
7  * modify it under the terms of the GNU Lesser General Public
8  * License as published by the Free Software Foundation; either
9  * version 3 of the License, or (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14  * Lesser General Public License for more details.
15  *
16  * You should have received a copy of the GNU Lesser General Public License
17  * along with this program; if not, write to the Free Software Foundation,
18  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19  */
20 package org.sonar.server.notification.email;
21
22 import java.net.MalformedURLException;
23 import java.net.URL;
24 import java.time.Duration;
25 import java.util.Objects;
26 import java.util.Set;
27 import java.util.regex.Pattern;
28 import javax.annotation.CheckForNull;
29 import org.apache.commons.lang3.StringUtils;
30 import org.apache.commons.mail.Email;
31 import org.apache.commons.mail.EmailException;
32 import org.apache.commons.mail.HtmlEmail;
33 import org.apache.commons.mail.SimpleEmail;
34 import org.slf4j.Logger;
35 import org.slf4j.LoggerFactory;
36 import org.sonar.api.config.EmailSettings;
37 import org.sonar.api.notifications.Notification;
38 import org.sonar.api.user.User;
39 import org.sonar.api.utils.SonarException;
40 import org.sonar.db.DbClient;
41 import org.sonar.db.DbSession;
42 import org.sonar.db.user.UserDto;
43 import org.sonar.server.issue.notification.EmailMessage;
44 import org.sonar.server.issue.notification.EmailTemplate;
45 import org.sonar.server.notification.NotificationChannel;
46
47 import static java.time.temporal.ChronoUnit.SECONDS;
48 import static java.util.Objects.requireNonNull;
49
50 /**
51  * References:
52  * <ul>
53  * <li><a href="http://tools.ietf.org/html/rfc4021">Registration of Mail and MIME Header Fields</a></li>
54  * <li><a href="http://tools.ietf.org/html/rfc2919">List-Id: A Structured Field and Namespace for the Identification of Mailing Lists</a></li>
55  * <li><a href="https://github.com/blog/798-threaded-email-notifications">GitHub: Threaded Email Notifications</a></li>
56  * </ul>
57  *
58  * @since 2.10
59  */
60 public class EmailNotificationChannel extends NotificationChannel {
61
62   private static final Logger LOG = LoggerFactory.getLogger(EmailNotificationChannel.class);
63
64   /**
65    * @see org.apache.commons.mail.Email#setSocketConnectionTimeout(Duration)
66    * @see org.apache.commons.mail.Email#setSocketTimeout(Duration)
67    */
68   private static final Duration SOCKET_TIMEOUT = Duration.of(30, SECONDS);
69
70   private static final Pattern PATTERN_LINE_BREAK = Pattern.compile("[\n\r]");
71
72   /**
73    * Email Header Field: "List-ID".
74    * Value of this field should contain mailing list identifier as specified in <a href="http://tools.ietf.org/html/rfc2919">RFC 2919</a>.
75    */
76   private static final String LIST_ID_HEADER = "List-ID";
77
78   /**
79    * Email Header Field: "List-Archive".
80    * Value of this field should contain URL of mailing list archive as specified in <a href="http://tools.ietf.org/html/rfc2369">RFC 2369</a>.
81    */
82   private static final String LIST_ARCHIVE_HEADER = "List-Archive";
83
84   /**
85    * Email Header Field: "In-Reply-To".
86    * Value of this field should contain related message identifier as specified in <a href="http://tools.ietf.org/html/rfc2822">RFC 2822</a>.
87    */
88   private static final String IN_REPLY_TO_HEADER = "In-Reply-To";
89
90   /**
91    * Email Header Field: "References".
92    * Value of this field should contain related message identifier as specified in <a href="http://tools.ietf.org/html/rfc2822">RFC 2822</a>
93    */
94   private static final String REFERENCES_HEADER = "References";
95
96   private static final String SUBJECT_DEFAULT = "Notification";
97   private static final String SMTP_HOST_NOT_CONFIGURED_DEBUG_MSG = "SMTP host was not configured - email will not be sent";
98   private static final String MAIL_SENT_FROM = "%sMail sent from: %s";
99
100   private final EmailSettings configuration;
101   private final EmailTemplate[] templates;
102   private final DbClient dbClient;
103
104   public EmailNotificationChannel(EmailSettings configuration, EmailTemplate[] templates, DbClient dbClient) {
105     this.configuration = configuration;
106     this.templates = templates;
107     this.dbClient = dbClient;
108   }
109
110   public boolean isActivated() {
111     return !StringUtils.isBlank(configuration.getSmtpHost());
112   }
113
114   @Override
115   public boolean deliver(Notification notification, String username) {
116     if (!isActivated()) {
117       LOG.debug(SMTP_HOST_NOT_CONFIGURED_DEBUG_MSG);
118       return false;
119     }
120
121     User user = findByLogin(username);
122     if (user == null || StringUtils.isBlank(user.email())) {
123       LOG.debug("User does not exist or has no email: {}", username);
124       return false;
125     }
126
127     EmailMessage emailMessage = format(notification);
128     if (emailMessage != null) {
129       emailMessage.setTo(user.email());
130       return deliver(emailMessage);
131     }
132     return false;
133   }
134
135   public record EmailDeliveryRequest(String recipientEmail, Notification notification) {
136     public EmailDeliveryRequest(String recipientEmail, Notification notification) {
137       this.recipientEmail = requireNonNull(recipientEmail, "recipientEmail can't be null");
138       this.notification = requireNonNull(notification, "notification can't be null");
139     }
140
141     @Override
142     public boolean equals(Object o) {
143       if (this == o) {
144         return true;
145       }
146       if (o == null || getClass() != o.getClass()) {
147         return false;
148       }
149       EmailDeliveryRequest that = (EmailDeliveryRequest) o;
150       return Objects.equals(recipientEmail, that.recipientEmail) &&
151         Objects.equals(notification, that.notification);
152     }
153
154     @Override
155     public String toString() {
156       return "EmailDeliveryRequest{" + "'" + recipientEmail + '\'' + " : " + notification + '}';
157     }
158   }
159
160   public int deliverAll(Set<EmailDeliveryRequest> deliveries) {
161     if (deliveries.isEmpty() || !isActivated()) {
162       LOG.debug(SMTP_HOST_NOT_CONFIGURED_DEBUG_MSG);
163       return 0;
164     }
165
166     return (int) deliveries.stream()
167       .filter(t -> !t.recipientEmail().isBlank())
168       .map(t -> {
169         EmailMessage emailMessage = format(t.notification());
170         if (emailMessage != null) {
171           emailMessage.setTo(t.recipientEmail());
172           return deliver(emailMessage);
173         }
174         return false;
175       })
176       .filter(Boolean::booleanValue)
177       .count();
178   }
179
180   @CheckForNull
181   private User findByLogin(String login) {
182     try (DbSession dbSession = dbClient.openSession(false)) {
183       UserDto dto = dbClient.userDao().selectActiveUserByLogin(dbSession, login);
184       return dto != null ? dto.toUser() : null;
185     }
186   }
187
188   private EmailMessage format(Notification notification) {
189     for (EmailTemplate template : templates) {
190       EmailMessage email = template.format(notification);
191       if (email != null) {
192         return email;
193       }
194     }
195     LOG.warn("Email template not found for notification: {}", notification);
196     return null;
197   }
198
199   boolean deliver(EmailMessage emailMessage) {
200     if (!isActivated()) {
201       LOG.debug(SMTP_HOST_NOT_CONFIGURED_DEBUG_MSG);
202       return false;
203     }
204     try {
205       send(emailMessage);
206       return true;
207     } catch (EmailException e) {
208       LOG.error("Unable to send email", e);
209       return false;
210     }
211   }
212
213   private void send(EmailMessage emailMessage) throws EmailException {
214     // Trick to correctly initialize javax.mail library
215     ClassLoader classloader = Thread.currentThread().getContextClassLoader();
216     Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
217
218     try {
219       LOG.atTrace().setMessage("Sending email: {}")
220         .addArgument(() -> sanitizeLog(emailMessage.getMessage()))
221         .log();
222       String host = resolveHost();
223
224       Email email = createEmailWithMessage(emailMessage);
225       setHeaders(email, emailMessage, host);
226       setConnectionDetails(email);
227       setToAndFrom(email, emailMessage);
228       setSubject(email, emailMessage);
229       email.send();
230
231     } finally {
232       Thread.currentThread().setContextClassLoader(classloader);
233     }
234   }
235
236   private static String sanitizeLog(String message) {
237     return PATTERN_LINE_BREAK.matcher(message).replaceAll("_");
238   }
239
240   private static Email createEmailWithMessage(EmailMessage emailMessage) throws EmailException {
241     if (emailMessage.isHtml()) {
242       return new HtmlEmail().setHtmlMsg(emailMessage.getMessage());
243     }
244     return new SimpleEmail().setMsg(emailMessage.getMessage());
245   }
246
247   private void setSubject(Email email, EmailMessage emailMessage) {
248     String subject = StringUtils.defaultIfBlank(StringUtils.trimToEmpty(configuration.getPrefix()) + " ", "")
249       + StringUtils.defaultString(emailMessage.getSubject(), SUBJECT_DEFAULT);
250     email.setSubject(subject);
251   }
252
253   private void setToAndFrom(Email email, EmailMessage emailMessage) throws EmailException {
254     String fromName = configuration.getFromName();
255     String from = StringUtils.isBlank(emailMessage.getFrom()) ? fromName : (emailMessage.getFrom() + " (" + fromName + ")");
256     email.setFrom(configuration.getFrom(), from);
257     email.addTo(emailMessage.getTo(), " ");
258   }
259
260   @CheckForNull
261   private String resolveHost() {
262     try {
263       return new URL(configuration.getServerBaseURL()).getHost();
264     } catch (MalformedURLException e) {
265       // ignore
266       return null;
267     }
268   }
269
270   private void setHeaders(Email email, EmailMessage emailMessage, @CheckForNull String host) {
271     // Set general information
272     email.setCharset("UTF-8");
273     if (StringUtils.isNotBlank(host)) {
274       /*
275        * Set headers for proper threading: GMail will not group messages, even if they have same subject, but don't have "In-Reply-To" and
276        * "References" headers. TODO investigate threading in other clients like KMail, Thunderbird, Outlook
277        */
278       if (StringUtils.isNotEmpty(emailMessage.getMessageId())) {
279         String messageId = "<" + emailMessage.getMessageId() + "@" + host + ">";
280         email.addHeader(IN_REPLY_TO_HEADER, messageId);
281         email.addHeader(REFERENCES_HEADER, messageId);
282       }
283       // Set headers for proper filtering
284       email.addHeader(LIST_ID_HEADER, "SonarQube <sonar." + host + ">");
285       email.addHeader(LIST_ARCHIVE_HEADER, configuration.getServerBaseURL());
286     }
287   }
288
289   private void setConnectionDetails(Email email) {
290     email.setHostName(configuration.getSmtpHost());
291     configureSecureConnection(email);
292     if (StringUtils.isNotBlank(configuration.getSmtpUsername()) || StringUtils.isNotBlank(configuration.getSmtpPassword())) {
293       email.setAuthentication(configuration.getSmtpUsername(), configuration.getSmtpPassword());
294     }
295     email.setSocketConnectionTimeout(SOCKET_TIMEOUT);
296     email.setSocketTimeout(SOCKET_TIMEOUT);
297   }
298
299   private void configureSecureConnection(Email email) {
300     if (StringUtils.equalsIgnoreCase(configuration.getSecureConnection(), "ssl")) {
301       email.setSSLOnConnect(true);
302       email.setSSLCheckServerIdentity(true);
303       email.setSslSmtpPort(String.valueOf(configuration.getSmtpPort()));
304
305       // this port is not used except in EmailException message, that's why it's set with the same value than SSL port.
306       // It prevents from getting bad message.
307       email.setSmtpPort(configuration.getSmtpPort());
308     } else if (StringUtils.equalsIgnoreCase(configuration.getSecureConnection(), "starttls")) {
309       email.setStartTLSEnabled(true);
310       email.setStartTLSRequired(true);
311       email.setSSLCheckServerIdentity(true);
312       email.setSmtpPort(configuration.getSmtpPort());
313     } else if (StringUtils.isBlank(configuration.getSecureConnection())) {
314       email.setSmtpPort(configuration.getSmtpPort());
315     } else {
316       throw new SonarException("Unknown type of SMTP secure connection: " + configuration.getSecureConnection());
317     }
318   }
319
320   /**
321    * Send test email.
322    *
323    * @throws EmailException when unable to send
324    */
325   public void sendTestEmail(String toAddress, String subject, String message) throws EmailException {
326     try {
327       EmailMessage emailMessage = new EmailMessage();
328       emailMessage.setTo(toAddress);
329       emailMessage.setSubject(subject);
330       emailMessage.setPlainTextMessage(message + getServerBaseUrlFooter());
331       send(emailMessage);
332     } catch (EmailException e) {
333       LOG.debug("Fail to send test email to {}: {}", toAddress, e);
334       throw e;
335     }
336   }
337
338   private String getServerBaseUrlFooter() {
339     return String.format(MAIL_SENT_FROM, "\n\n", configuration.getServerBaseURL());
340   }
341
342 }