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