3 * Copyright (C) 2009-2022 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.sonar.core.util.stream.MoreCollectors.index;
50 import static org.sonar.server.issue.notification.RuleGroup.ISSUES;
51 import static org.sonar.server.issue.notification.RuleGroup.SECURITY_HOTSPOTS;
52 import static org.sonar.server.issue.notification.RuleGroup.formatIssueOrHotspot;
53 import static org.sonar.server.issue.notification.RuleGroup.formatIssuesOrHotspots;
54 import static org.sonar.server.issue.notification.RuleGroup.resolveGroup;
56 public abstract class IssueChangesEmailTemplate implements EmailTemplate {
58 private static final Comparator<Rule> RULE_COMPARATOR = Comparator.comparing(r -> r.getKey().toString());
59 private static final Comparator<Project> PROJECT_COMPARATOR = Comparator.comparing(Project::getProjectName)
60 .thenComparing(t -> t.getBranchName().orElse(""));
61 private static final Comparator<ChangedIssue> CHANGED_ISSUE_KEY_COMPARATOR = Comparator.comparing(ChangedIssue::getKey, Comparator.naturalOrder());
66 * <li>UUID length of 40 chars</li>
67 * <li>a max URL length of 2083 chars</li>
69 * This leaves ~850 chars for the rest of the URL (including other parameters such as the project key and the branch),
70 * which is reasonable to stay safe from the max URL length supported by some browsers and network devices.
72 private static final int MAX_ISSUES_BY_LINK = 40;
73 private static final String URL_ENCODED_COMMA = urlEncode(",");
75 private final I18n i18n;
76 private final EmailSettings settings;
78 protected IssueChangesEmailTemplate(I18n i18n, EmailSettings settings) {
80 this.settings = settings;
84 * Adds "projectName" or "projectName, branchName" if branchName is non null
86 protected static void toString(StringBuilder sb, Project project) {
87 Optional<String> branchName = project.getBranchName();
88 if (branchName.isPresent()) {
89 sb.append(project.getProjectName()).append(", ").append(branchName.get());
91 sb.append(project.getProjectName());
95 static String toUrlParams(Project project) {
96 return "id=" + urlEncode(project.getKey()) +
97 project.getBranchName().map(branchName -> "&branch=" + urlEncode(branchName)).orElse("");
100 void addIssuesByProjectThenRule(StringBuilder sb, SetMultimap<Project, ChangedIssue> issuesByProject) {
101 issuesByProject.keySet().stream()
102 .sorted(PROJECT_COMPARATOR)
103 .forEach(project -> {
104 String encodedProjectParams = toUrlParams(project);
105 paragraph(sb, s -> toString(s, project));
106 addIssuesByRule(sb, issuesByProject.get(project), projectIssuePageHref(encodedProjectParams));
110 void addIssuesAndHotspotsByProjectThenRule(StringBuilder sb, SetMultimap<Project, ChangedIssue> issuesByProject) {
111 issuesByProject.keySet().stream()
112 .sorted(PROJECT_COMPARATOR)
113 .forEach(project -> {
114 String encodedProjectParams = toUrlParams(project);
115 paragraph(sb, s -> toString(s, project));
117 Set<ChangedIssue> changedIssues = issuesByProject.get(project);
118 ListMultimap<RuleGroup, ChangedIssue> issuesAndHotspots = changedIssues.stream()
119 .collect(index(changedIssue -> resolveGroup(changedIssue.getRule().getRuleType()), identity()));
121 List<ChangedIssue> issues = issuesAndHotspots.get(ISSUES);
122 List<ChangedIssue> hotspots = issuesAndHotspots.get(SECURITY_HOTSPOTS);
124 boolean hasSecurityHotspots = !hotspots.isEmpty();
125 boolean hasOtherIssues = !issues.isEmpty();
127 if (hasOtherIssues) {
128 addIssuesByRule(sb, issues, projectIssuePageHref(encodedProjectParams));
131 if (hasSecurityHotspots && hasOtherIssues) {
132 paragraph(sb, stringBuilder -> {
136 if (hasSecurityHotspots) {
137 addIssuesByRule(sb, hotspots, securityHotspotPageHref(encodedProjectParams));
142 void addIssuesByRule(StringBuilder sb, Collection<ChangedIssue> changedIssues, BiConsumer<StringBuilder, Collection<ChangedIssue>> issuePageHref) {
143 ListMultimap<Rule, ChangedIssue> issuesByRule = changedIssues.stream()
144 .collect(index(ChangedIssue::getRule, t -> t));
146 Iterator<Rule> rules = issuesByRule.keySet().stream()
147 .sorted(RULE_COMPARATOR)
149 if (!rules.hasNext()) {
154 while (rules.hasNext()) {
155 Rule rule = rules.next();
156 Collection<ChangedIssue> issues = issuesByRule.get(rule);
158 sb.append("<li>").append("Rule ").append(" <em>").append(rule.getName()).append("</em> - ");
159 appendIssueLinks(sb, issuePageHref, issues, rule.getRuleType());
165 private static void appendIssueLinks(StringBuilder sb, BiConsumer<StringBuilder, Collection<ChangedIssue>> issuePageHref, Collection<ChangedIssue> issues,
166 @Nullable RuleType ruleType) {
167 SortedSet<ChangedIssue> sortedIssues = ImmutableSortedSet.copyOf(CHANGED_ISSUE_KEY_COMPARATOR, issues);
168 int issueCount = issues.size();
169 if (issueCount == 1) {
170 link(sb, s -> issuePageHref.accept(s, sortedIssues), s -> s.append("See the single ").append(formatIssueOrHotspot(ruleType)));
171 } else if (issueCount <= MAX_ISSUES_BY_LINK) {
172 link(sb, s -> issuePageHref.accept(s, sortedIssues), s -> s.append("See all ").append(issueCount).append(" ").append(formatIssuesOrHotspots(ruleType)));
174 sb.append("See ").append(formatIssuesOrHotspots(ruleType));
175 List<List<ChangedIssue>> issueGroups = Lists.partition(ImmutableList.copyOf(sortedIssues), MAX_ISSUES_BY_LINK);
176 Iterator<List<ChangedIssue>> issueGroupsIterator = issueGroups.iterator();
177 int[] groupIndex = new int[] {0};
178 while (issueGroupsIterator.hasNext()) {
179 List<ChangedIssue> issueGroup = issueGroupsIterator.next();
181 link(sb, s -> issuePageHref.accept(s, issueGroup), issueGroupLabel(sb, groupIndex, issueGroup));
187 BiConsumer<StringBuilder, Collection<ChangedIssue>> projectIssuePageHref(String projectParams) {
188 return (s, issues) -> {
189 s.append(settings.getServerBaseURL()).append("/project/issues?").append(projectParams)
192 Iterator<ChangedIssue> issueIterator = issues.iterator();
193 while (issueIterator.hasNext()) {
194 s.append(urlEncode(issueIterator.next().getKey()));
195 if (issueIterator.hasNext()) {
196 s.append(URL_ENCODED_COMMA);
200 if (issues.size() == 1) {
201 s.append("&open=").append(urlEncode(issues.iterator().next().getKey()));
206 BiConsumer<StringBuilder, Collection<ChangedIssue>> securityHotspotPageHref(String projectParams) {
207 return (s, issues) -> {
208 s.append(settings.getServerBaseURL()).append("/security_hotspots?").append(projectParams)
209 .append("&hotspots=");
211 Iterator<ChangedIssue> issueIterator = issues.iterator();
212 while (issueIterator.hasNext()) {
213 s.append(urlEncode(issueIterator.next().getKey()));
214 if (issueIterator.hasNext()) {
215 s.append(URL_ENCODED_COMMA);
221 private static Consumer<StringBuilder> issueGroupLabel(StringBuilder sb, int[] groupIndex, List<ChangedIssue> issueGroup) {
223 int firstIssueNumber = (groupIndex[0] * MAX_ISSUES_BY_LINK) + 1;
224 if (issueGroup.size() == 1) {
225 sb.append(firstIssueNumber);
227 sb.append(firstIssueNumber).append("-").append(firstIssueNumber + issueGroup.size() - 1);
232 void addFooter(StringBuilder sb, String notificationI18nKey) {
233 paragraph(sb, s -> s.append(" "));
236 s.append("You received this email because you are subscribed to ")
237 .append('"').append(i18n.message(Locale.ENGLISH, notificationI18nKey, notificationI18nKey)).append('"')
238 .append(" notifications from SonarQube.");
240 link(s, s1 -> s1.append(settings.getServerBaseURL()).append("/account/notifications"), s1 -> s1.append("here"));
241 s.append(" to edit your email preferences.");
242 s.append("</small>");
246 protected static void paragraph(StringBuilder sb, Consumer<StringBuilder> content) {
252 protected static void link(StringBuilder sb, Consumer<StringBuilder> link, Consumer<StringBuilder> content) {
253 sb.append("<a href=\"");
260 private static String urlEncode(String str) {
262 return encode(str, UTF_8.name());
263 } catch (UnsupportedEncodingException e) {
264 throw new IllegalStateException(e);
268 protected static String issueOrIssues(Collection<?> collection) {
269 if (collection.size() > 1) {