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