3 * Copyright (C) 2009-2024 SonarSource SA
4 * mailto:info AT sonarsource DOT com
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.
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.
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.
20 package org.sonar.server.notification.email;
22 import java.net.MalformedURLException;
24 import java.time.Duration;
25 import java.util.Objects;
26 import java.util.Properties;
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;
50 import static java.time.temporal.ChronoUnit.SECONDS;
51 import static java.util.Objects.requireNonNull;
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>
63 public class EmailNotificationChannel extends NotificationChannel {
65 private static final Logger LOG = LoggerFactory.getLogger(EmailNotificationChannel.class);
68 * @see org.apache.commons.mail.Email#setSocketConnectionTimeout(Duration)
69 * @see org.apache.commons.mail.Email#setSocketTimeout(Duration)
71 private static final Duration SOCKET_TIMEOUT = Duration.of(30, SECONDS);
73 private static final Pattern PATTERN_LINE_BREAK = Pattern.compile("[\n\r]");
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>.
79 private static final String LIST_ID_HEADER = "List-ID";
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>.
85 private static final String LIST_ARCHIVE_HEADER = "List-Archive";
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>.
91 private static final String IN_REPLY_TO_HEADER = "In-Reply-To";
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>
97 private static final String REFERENCES_HEADER = "References";
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";
104 private final EmailSmtpConfiguration configuration;
105 private final Server server;
106 private final EmailTemplate[] templates;
107 private final DbClient dbClient;
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;
116 public boolean isActivated() {
117 return !StringUtils.isBlank(configuration.getSmtpHost());
121 public boolean deliver(Notification notification, String username) {
122 if (!isActivated()) {
123 LOG.debug(SMTP_HOST_NOT_CONFIGURED_DEBUG_MSG);
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);
133 EmailMessage emailMessage = format(notification);
134 if (emailMessage != null) {
135 emailMessage.setTo(user.email());
136 return deliver(emailMessage);
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");
148 public boolean equals(Object o) {
152 if (o == null || getClass() != o.getClass()) {
155 EmailDeliveryRequest that = (EmailDeliveryRequest) o;
156 return Objects.equals(recipientEmail, that.recipientEmail) &&
157 Objects.equals(notification, that.notification);
161 public String toString() {
162 return "EmailDeliveryRequest{" + "'" + recipientEmail + '\'' + " : " + notification + '}';
166 public int deliverAll(Set<EmailDeliveryRequest> deliveries) {
167 if (deliveries.isEmpty() || !isActivated()) {
168 LOG.debug(SMTP_HOST_NOT_CONFIGURED_DEBUG_MSG);
172 return (int) deliveries.stream()
173 .filter(t -> !t.recipientEmail().isBlank())
175 EmailMessage emailMessage = format(t.notification());
176 if (emailMessage != null) {
177 emailMessage.setTo(t.recipientEmail());
178 return deliver(emailMessage);
182 .filter(Boolean::booleanValue)
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;
194 private EmailMessage format(Notification notification) {
195 for (EmailTemplate template : templates) {
196 EmailMessage email = template.format(notification);
201 LOG.warn("Email template not found for notification: {}", notification);
205 boolean deliver(EmailMessage emailMessage) {
206 if (!isActivated()) {
207 LOG.debug(SMTP_HOST_NOT_CONFIGURED_DEBUG_MSG);
213 } catch (EmailException e) {
214 LOG.error("Unable to send email", e);
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());
225 LOG.atTrace().setMessage("Sending email: {}")
226 .addArgument(() -> sanitizeLog(emailMessage.getMessage()))
228 String host = resolveHost();
230 Email email = createEmailWithMessage(emailMessage);
231 setHeaders(email, emailMessage, host);
232 setConnectionDetails(email);
233 setToAndFrom(email, emailMessage);
234 setSubject(email, emailMessage);
238 Thread.currentThread().setContextClassLoader(classloader);
242 private static String sanitizeLog(String message) {
243 return PATTERN_LINE_BREAK.matcher(message).replaceAll("_");
246 private static Email createEmailWithMessage(EmailMessage emailMessage) throws EmailException {
247 if (emailMessage.isHtml()) {
248 return new HtmlEmail().setHtmlMsg(emailMessage.getMessage());
250 return new SimpleEmail().setMsg(emailMessage.getMessage());
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);
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(), " ");
267 private String resolveHost() {
269 return new URL(server.getPublicRootUrl()).getHost();
270 } catch (MalformedURLException e) {
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)) {
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
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);
289 // Set headers for proper filtering
290 email.addHeader(LIST_ID_HEADER, "SonarQube <sonar." + host + ">");
291 email.addHeader(LIST_ARCHIVE_HEADER, server.getPublicRootUrl());
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);
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");
317 private void setBasicAuthentication(Email email) {
318 if (StringUtils.isNotBlank(configuration.getSmtpUsername()) || StringUtils.isNotBlank(configuration.getSmtpPassword())) {
319 email.setAuthentication(configuration.getSmtpUsername(), configuration.getSmtpPassword());
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()));
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());
340 throw new SonarException("Unknown type of SMTP secure connection: " + configuration.getSecureConnection());
347 * @throws EmailException when unable to send
349 public void sendTestEmail(String toAddress, String subject, String message) throws EmailException {
351 EmailMessage emailMessage = new EmailMessage();
352 emailMessage.setTo(toAddress);
353 emailMessage.setSubject(subject);
354 emailMessage.setPlainTextMessage(message + getServerBaseUrlFooter());
356 } catch (EmailException e) {
357 LOG.debug("Fail to send test email to {}: {}", toAddress, e);
362 private String getServerBaseUrlFooter() {
363 return String.format(MAIL_SENT_FROM, "\n\n", server.getPublicRootUrl());