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.issue.notification;
22 import com.google.common.collect.ImmutableList;
23 import com.google.common.collect.ImmutableSortedSet;
24 import com.google.common.collect.ListMultimap;
25 import com.google.common.collect.Lists;
26 import com.google.common.collect.SetMultimap;
27 import java.io.UnsupportedEncodingException;
28 import java.util.Collection;
29 import java.util.Comparator;
30 import java.util.Iterator;
31 import java.util.List;
32 import java.util.Locale;
33 import java.util.Optional;
35 import java.util.SortedSet;
36 import java.util.function.BiConsumer;
37 import java.util.function.Consumer;
38 import javax.annotation.Nullable;
39 import org.sonar.api.config.EmailSettings;
40 import org.sonar.api.rules.RuleType;
41 import org.sonar.core.i18n.I18n;
42 import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue;
43 import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project;
44 import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule;
46 import static java.net.URLEncoder.encode;
47 import static java.nio.charset.StandardCharsets.UTF_8;
48 import static java.util.function.Function.identity;
49 import static org.apache.commons.lang.StringEscapeUtils.escapeHtml;
50 import static org.sonar.core.util.stream.MoreCollectors.index;
51 import static org.sonar.server.issue.notification.RuleGroup.ISSUES;
52 import static org.sonar.server.issue.notification.RuleGroup.SECURITY_HOTSPOTS;
53 import static org.sonar.server.issue.notification.RuleGroup.formatIssueOrHotspot;
54 import static org.sonar.server.issue.notification.RuleGroup.formatIssuesOrHotspots;
55 import static org.sonar.server.issue.notification.RuleGroup.resolveGroup;
57 public abstract class IssueChangesEmailTemplate implements EmailTemplate {
59 private static final Comparator<Rule> RULE_COMPARATOR = Comparator.comparing(r -> r.getKey().toString());
60 private static final Comparator<Project> PROJECT_COMPARATOR = Comparator.comparing(Project::getProjectName)
61 .thenComparing(t -> t.getBranchName().orElse(""));
62 private static final Comparator<ChangedIssue> CHANGED_ISSUE_KEY_COMPARATOR = Comparator.comparing(ChangedIssue::getKey, Comparator.naturalOrder());
67 * <li>UUID length of 40 chars</li>
68 * <li>a max URL length of 2083 chars</li>
70 * This leaves ~850 chars for the rest of the URL (including other parameters such as the project key and the branch),
71 * which is reasonable to stay safe from the max URL length supported by some browsers and network devices.
73 private static final int MAX_ISSUES_BY_LINK = 40;
74 private static final String URL_ENCODED_COMMA = urlEncode(",");
76 private final I18n i18n;
77 private final EmailSettings settings;
79 protected IssueChangesEmailTemplate(I18n i18n, EmailSettings settings) {
81 this.settings = settings;
85 * Adds "projectName" or "projectName, branchName" if branchName is non null
87 protected static void toString(StringBuilder sb, Project project) {
88 Optional<String> branchName = project.getBranchName();
89 String value = project.getProjectName();
90 if (branchName.isPresent()) {
91 value += ", " + branchName.get();
93 sb.append(escapeHtml(value));
96 static String toUrlParams(Project project) {
97 return "id=" + urlEncode(project.getKey()) +
98 project.getBranchName().map(branchName -> "&branch=" + urlEncode(branchName)).orElse("");
101 void addIssuesByProjectThenRule(StringBuilder sb, SetMultimap<Project, ChangedIssue> issuesByProject) {
102 issuesByProject.keySet().stream()
103 .sorted(PROJECT_COMPARATOR)
104 .forEach(project -> {
105 String encodedProjectParams = toUrlParams(project);
106 paragraph(sb, s -> toString(s, project));
107 addIssuesByRule(sb, issuesByProject.get(project), projectIssuePageHref(encodedProjectParams));
111 void addIssuesAndHotspotsByProjectThenRule(StringBuilder sb, SetMultimap<Project, ChangedIssue> issuesByProject) {
112 issuesByProject.keySet().stream()
113 .sorted(PROJECT_COMPARATOR)
114 .forEach(project -> {
115 String encodedProjectParams = toUrlParams(project);
116 paragraph(sb, s -> toString(s, project));
118 Set<ChangedIssue> changedIssues = issuesByProject.get(project);
119 ListMultimap<RuleGroup, ChangedIssue> issuesAndHotspots = changedIssues.stream()
120 .collect(index(changedIssue -> resolveGroup(changedIssue.getRule().getRuleType()), identity()));
122 List<ChangedIssue> issues = issuesAndHotspots.get(ISSUES);
123 List<ChangedIssue> hotspots = issuesAndHotspots.get(SECURITY_HOTSPOTS);
125 boolean hasSecurityHotspots = !hotspots.isEmpty();
126 boolean hasOtherIssues = !issues.isEmpty();
128 if (hasOtherIssues) {
129 addIssuesByRule(sb, issues, projectIssuePageHref(encodedProjectParams));
132 if (hasSecurityHotspots && hasOtherIssues) {
133 paragraph(sb, stringBuilder -> {
137 if (hasSecurityHotspots) {
138 addIssuesByRule(sb, hotspots, securityHotspotPageHref(encodedProjectParams));
143 void addIssuesByRule(StringBuilder sb, Collection<ChangedIssue> changedIssues, BiConsumer<StringBuilder, Collection<ChangedIssue>> issuePageHref) {
144 ListMultimap<Rule, ChangedIssue> issuesByRule = changedIssues.stream()
145 .collect(index(ChangedIssue::getRule, t -> t));
147 Iterator<Rule> rules = issuesByRule.keySet().stream()
148 .sorted(RULE_COMPARATOR)
150 if (!rules.hasNext()) {
155 while (rules.hasNext()) {
156 Rule rule = rules.next();
157 Collection<ChangedIssue> issues = issuesByRule.get(rule);
159 String ruleName = escapeHtml(rule.getName());
160 sb.append("<li>").append("Rule ").append(" <em>").append(ruleName).append("</em> - ");
161 appendIssueLinks(sb, issuePageHref, issues, rule.getRuleType());
167 private static void appendIssueLinks(StringBuilder sb, BiConsumer<StringBuilder, Collection<ChangedIssue>> issuePageHref, Collection<ChangedIssue> issues,
168 @Nullable RuleType ruleType) {
169 SortedSet<ChangedIssue> sortedIssues = ImmutableSortedSet.copyOf(CHANGED_ISSUE_KEY_COMPARATOR, issues);
170 int issueCount = issues.size();
171 if (issueCount == 1) {
172 link(sb, s -> issuePageHref.accept(s, sortedIssues), s -> s.append("See the single ").append(formatIssueOrHotspot(ruleType)));
173 } else if (issueCount <= MAX_ISSUES_BY_LINK) {
174 link(sb, s -> issuePageHref.accept(s, sortedIssues), s -> s.append("See all ").append(issueCount).append(" ").append(formatIssuesOrHotspots(ruleType)));
176 sb.append("See ").append(formatIssuesOrHotspots(ruleType));
177 List<List<ChangedIssue>> issueGroups = Lists.partition(ImmutableList.copyOf(sortedIssues), MAX_ISSUES_BY_LINK);
178 Iterator<List<ChangedIssue>> issueGroupsIterator = issueGroups.iterator();
179 int[] groupIndex = new int[] {0};
180 while (issueGroupsIterator.hasNext()) {
181 List<ChangedIssue> issueGroup = issueGroupsIterator.next();
183 link(sb, s -> issuePageHref.accept(s, issueGroup), issueGroupLabel(sb, groupIndex, issueGroup));
189 BiConsumer<StringBuilder, Collection<ChangedIssue>> projectIssuePageHref(String projectParams) {
190 return (s, issues) -> {
191 s.append(settings.getServerBaseURL()).append("/project/issues?").append(projectParams)
194 Iterator<ChangedIssue> issueIterator = issues.iterator();
195 while (issueIterator.hasNext()) {
196 s.append(urlEncode(issueIterator.next().getKey()));
197 if (issueIterator.hasNext()) {
198 s.append(URL_ENCODED_COMMA);
202 if (issues.size() == 1) {
203 s.append("&open=").append(urlEncode(issues.iterator().next().getKey()));
208 BiConsumer<StringBuilder, Collection<ChangedIssue>> securityHotspotPageHref(String projectParams) {
209 return (s, issues) -> {
210 s.append(settings.getServerBaseURL()).append("/security_hotspots?").append(projectParams)
211 .append("&hotspots=");
213 Iterator<ChangedIssue> issueIterator = issues.iterator();
214 while (issueIterator.hasNext()) {
215 s.append(urlEncode(issueIterator.next().getKey()));
216 if (issueIterator.hasNext()) {
217 s.append(URL_ENCODED_COMMA);
223 private static Consumer<StringBuilder> issueGroupLabel(StringBuilder sb, int[] groupIndex, List<ChangedIssue> issueGroup) {
225 int firstIssueNumber = (groupIndex[0] * MAX_ISSUES_BY_LINK) + 1;
226 if (issueGroup.size() == 1) {
227 sb.append(firstIssueNumber);
229 sb.append(firstIssueNumber).append("-").append(firstIssueNumber + issueGroup.size() - 1);
234 void addFooter(StringBuilder sb, String notificationI18nKey) {
235 paragraph(sb, s -> s.append(" "));
238 s.append("You received this email because you are subscribed to ")
239 .append('"').append(i18n.message(Locale.ENGLISH, notificationI18nKey, notificationI18nKey)).append('"')
240 .append(" notifications from SonarQube.");
242 link(s, s1 -> s1.append(settings.getServerBaseURL()).append("/account/notifications"), s1 -> s1.append("here"));
243 s.append(" to edit your email preferences.");
244 s.append("</small>");
248 protected static void paragraph(StringBuilder sb, Consumer<StringBuilder> content) {
254 protected static void link(StringBuilder sb, Consumer<StringBuilder> link, Consumer<StringBuilder> content) {
255 sb.append("<a href=\"");
262 private static String urlEncode(String str) {
264 return encode(str, UTF_8.name());
265 } catch (UnsupportedEncodingException e) {
266 throw new IllegalStateException(e);
270 protected static String issueOrIssues(Collection<?> collection) {
271 if (collection.size() > 1) {