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