Skip to content

Commit b7a285b

Browse files
authored
Merge pull request DSpace#10963 from atmire/main-fix-email-templates
Rework how subscription emails are send out
2 parents 1384a95 + b10377b commit b7a285b

10 files changed

Lines changed: 886 additions & 57 deletions

File tree

dspace-api/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,5 +959,11 @@
959959
<artifactId>mockwebserver</artifactId>
960960
<scope>test</scope>
961961
</dependency>
962+
<dependency>
963+
<groupId>com.icegreen</groupId>
964+
<artifactId>greenmail</artifactId>
965+
<version>1.6.5</version>
966+
<scope>test</scope>
967+
</dependency>
962968
</dependencies>
963969
</project>

dspace-api/src/main/java/org/dspace/content/crosswalk/SubscriptionDsoMetadataForEmailCompose.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import java.util.List;
1717
import java.util.Objects;
1818

19+
import org.apache.commons.lang3.tuple.ImmutablePair;
1920
import org.dspace.content.DSpaceObject;
2021
import org.dspace.content.Item;
2122
import org.dspace.content.service.ItemService;
@@ -31,7 +32,7 @@
3132
*/
3233
public class SubscriptionDsoMetadataForEmailCompose implements StreamDisseminationCrosswalk {
3334

34-
private List<String> metadata = new ArrayList<>();
35+
private List<ImmutablePair<String, String>> metadata = new ArrayList<>();
3536

3637
@Autowired
3738
private ItemService itemService;
@@ -46,7 +47,7 @@ public void disseminate(Context context, DSpaceObject dso, OutputStream out) thr
4647
if (dso.getType() == Constants.ITEM) {
4748
Item item = (Item) dso;
4849
PrintStream printStream = new PrintStream(out);
49-
for (String actualMetadata : metadata) {
50+
for (String actualMetadata : metadata.stream().map(ImmutablePair::getLeft).toList()) {
5051
String[] split = actualMetadata.split("\\.");
5152
String qualifier = null;
5253
if (split.length == 3) {
@@ -69,11 +70,11 @@ public String getMIMEType() {
6970
return "text/plain";
7071
}
7172

72-
public List<String> getMetadata() {
73+
public List<ImmutablePair<String, String>> getMetadata() {
7374
return metadata;
7475
}
7576

76-
public void setMetadata(List<String> metadata) {
77+
public void setMetadata(List<ImmutablePair<String, String>> metadata) {
7778
this.metadata = metadata;
7879
}
7980

dspace-api/src/main/java/org/dspace/subscriptions/ContentGenerator.java

Lines changed: 228 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,39 @@
77
*/
88
package org.dspace.subscriptions;
99

10-
import static java.nio.charset.StandardCharsets.UTF_8;
10+
import static java.util.stream.Collectors.groupingBy;
11+
import static java.util.stream.Collectors.joining;
12+
import static java.util.stream.Collectors.partitioningBy;
13+
import static java.util.stream.Collectors.toList;
1114
import static org.apache.commons.lang3.StringUtils.EMPTY;
1215

13-
import java.io.ByteArrayOutputStream;
16+
import java.time.ZoneOffset;
17+
import java.time.ZonedDateTime;
18+
import java.time.temporal.ChronoUnit;
19+
import java.util.ArrayList;
20+
import java.util.Collections;
1421
import java.util.HashMap;
22+
import java.util.HashSet;
1523
import java.util.List;
1624
import java.util.Locale;
1725
import java.util.Map;
1826
import java.util.Objects;
1927
import java.util.Optional;
28+
import java.util.Set;
29+
import java.util.concurrent.ConcurrentHashMap;
30+
import java.util.function.Function;
31+
import java.util.function.Predicate;
2032

33+
import org.apache.commons.lang3.StringUtils;
34+
import org.apache.commons.lang3.tuple.ImmutablePair;
2135
import org.apache.logging.log4j.LogManager;
2236
import org.apache.logging.log4j.Logger;
37+
import org.dspace.content.Collection;
38+
import org.dspace.content.Community;
39+
import org.dspace.content.DCDate;
2340
import org.dspace.content.Item;
24-
import org.dspace.content.crosswalk.StreamDisseminationCrosswalk;
41+
import org.dspace.content.MetadataValue;
42+
import org.dspace.content.crosswalk.SubscriptionDsoMetadataForEmailCompose;
2543
import org.dspace.content.service.ItemService;
2644
import org.dspace.core.Context;
2745
import org.dspace.core.Email;
@@ -36,36 +54,79 @@
3654
* which will handle the logic of sending the emails
3755
* in case of 'content' subscriptionType
3856
*/
57+
@org.springframework.stereotype.Component
3958
@SuppressWarnings("rawtypes")
4059
public class ContentGenerator implements SubscriptionGenerator<IndexableObject> {
4160

4261
private final Logger log = LogManager.getLogger(ContentGenerator.class);
4362

4463
@SuppressWarnings("unchecked")
45-
private Map<String, StreamDisseminationCrosswalk> entityType2Disseminator = new HashMap();
64+
private Map<String, SubscriptionDsoMetadataForEmailCompose> entityType2Disseminator = new HashMap();
4665

4766
@Autowired
4867
private ItemService itemService;
4968

69+
private static final int MAX_METADATA_VALUES = 3;
70+
private static final String NEW_ITEMS_LABEL_KEY = "org.dspace.subscriptions.ContentGenerator.new-items-label";
71+
private static final String MODIFIED_ITEMS_LABEL_KEY =
72+
"org.dspace.subscriptions.ContentGenerator.modified-items-label";
73+
private static final String INTRO_NEW_AND_MODIFIED_KEY =
74+
"org.dspace.subscriptions.ContentGenerator.intro.new-and-modified";
75+
private static final String INTRO_NEW_KEY = "org.dspace.subscriptions.ContentGenerator.intro.new";
76+
private static final String INTRO_MODIFIED_KEY = "org.dspace.subscriptions.ContentGenerator.intro.modified";
77+
private static final String COMMUNITY_NOTE_PREFIX_KEY =
78+
"org.dspace.subscriptions.ContentGenerator.community-note-prefix";
79+
private static final String COMMUNITY_NOTE_SUFFIX_KEY =
80+
"org.dspace.subscriptions.ContentGenerator.community-note-suffix";
81+
private static final String LINE_SEPARATOR = System.lineSeparator();
82+
83+
private static <T> Predicate<T> distinctByKey(Function<? super T, ?> keyExtractor) {
84+
Set<Object> seen = ConcurrentHashMap.newKeySet();
85+
return t -> seen.add(keyExtractor.apply(t));
86+
}
87+
5088
@Override
5189
public void notifyForSubscriptions(Context context, EPerson ePerson,
52-
List<IndexableObject> indexableComm,
53-
List<IndexableObject> indexableColl) {
90+
Map<Community, List<IndexableObject>> commMap,
91+
Map<Collection, List<IndexableObject>> collMap) {
5492
try {
5593
if (Objects.nonNull(ePerson)) {
5694
Locale supportedLocale = I18nUtil.getEPersonLocale(ePerson);
5795
Email email = Email.getEmail(I18nUtil.getEmailFilename(supportedLocale, "subscriptions_content"));
5896
email.addRecipient(ePerson.getEmail());
5997

60-
String bodyCommunities = generateBodyMail(context, indexableComm);
61-
String bodyCollections = generateBodyMail(context, indexableColl);
62-
if (bodyCommunities.equals(EMPTY) && bodyCollections.equals(EMPTY)) {
63-
log.debug("subscription(s) of eperson {} do(es) not match any new items: nothing to send" +
64-
" - exit silently", ePerson::getID);
98+
// Create a map which holds the collections and their corresponding community names if we have updates
99+
// That originally came from a community subscription, so we can refer to this in the email
100+
Map<Collection, String> collectionToCommunityNameMap = buildCommunityCollectionMap(commMap);
101+
List<IndexableObject> allItems = new ArrayList<>();
102+
if (commMap != null) {
103+
commMap.values().forEach(allItems::addAll);
104+
}
105+
if (collMap != null) {
106+
collMap.values().forEach(allItems::addAll);
107+
}
108+
109+
Map<Boolean, List<IndexableObject>> partitionedItems = allItems.stream()
110+
.filter(distinctByKey(IndexableObject::getID))
111+
.collect(partitioningBy(obj -> isNewItem((Item) obj.getIndexedObject())));
112+
113+
Map<Collection, List<IndexableObject>> newItemsByCollection = partitionedItems.get(true).stream()
114+
.collect(groupingBy(obj -> ((Item) obj.getIndexedObject()).getOwningCollection()));
115+
116+
Map<Collection, List<IndexableObject>> modifiedItemsByCollection = partitionedItems.get(false).stream()
117+
.collect(groupingBy(obj -> ((Item) obj.getIndexedObject()).getOwningCollection()));
118+
119+
String intro = buildIntro(newItemsByCollection, modifiedItemsByCollection, supportedLocale);
120+
String combinedSection = buildCombinedSection(newItemsByCollection, modifiedItemsByCollection,
121+
collectionToCommunityNameMap, supportedLocale);
122+
123+
if (combinedSection.equals(EMPTY)) {
124+
log.debug("subscription(s) of eperson {} do(es) not match any new or modified items: " +
125+
"nothing to send - exit silently", ePerson::getID);
65126
return;
66127
}
67-
email.addArgument(bodyCommunities);
68-
email.addArgument(bodyCollections);
128+
email.addArgument(intro);
129+
email.addArgument(combinedSection);
69130
email.send();
70131
}
71132
} catch (Exception e) {
@@ -74,30 +135,166 @@ public void notifyForSubscriptions(Context context, EPerson ePerson,
74135
}
75136
}
76137

77-
private String generateBodyMail(Context context, List<IndexableObject> indexableObjects) {
78-
if (indexableObjects == null || indexableObjects.isEmpty()) {
138+
private static Map<Collection, String> buildCommunityCollectionMap(Map<Community, List<IndexableObject>> commMap) {
139+
if (commMap == null) {
140+
return Collections.emptyMap();
141+
}
142+
Map<Collection, String> resultMap = new HashMap<>();
143+
commMap.forEach((community, items) -> items.forEach(obj -> {
144+
Item item = (Item) obj.getIndexedObject();
145+
Collection collection = item.getOwningCollection();
146+
resultMap.putIfAbsent(collection, community.getName());
147+
}));
148+
return resultMap;
149+
}
150+
151+
private boolean isNewItem(Item item) {
152+
Optional<ZonedDateTime> createdDate =
153+
itemService.getMetadata(item, "dc", "date", "accessioned", Item.ANY)
154+
.stream()
155+
.map(MetadataValue::getValue)
156+
.findFirst()
157+
.map(val -> new DCDate(val).toDate().toInstant())
158+
.map(instant -> ZonedDateTime.ofInstant(instant, ZoneOffset.UTC));
159+
160+
ZonedDateTime lastModified = ZonedDateTime.ofInstant(item.getLastModified(), ZoneOffset.UTC);
161+
162+
return createdDate.map(createdZoned -> createdZoned.truncatedTo(ChronoUnit.SECONDS)
163+
.equals(lastModified.truncatedTo(ChronoUnit.SECONDS)))
164+
.orElse(false);
165+
}
166+
167+
private String buildIntro(Map<Collection, List<IndexableObject>> newItems,
168+
Map<Collection, List<IndexableObject>> modifiedItems,
169+
Locale locale) {
170+
if (!newItems.isEmpty() && !modifiedItems.isEmpty()) {
171+
return I18nUtil.getMessage(INTRO_NEW_AND_MODIFIED_KEY, locale);
172+
} else if (!newItems.isEmpty()) {
173+
return I18nUtil.getMessage(INTRO_NEW_KEY, locale);
174+
} else if (!modifiedItems.isEmpty()) {
175+
return I18nUtil.getMessage(INTRO_MODIFIED_KEY, locale);
176+
} else {
177+
return "";
178+
}
179+
}
180+
181+
private String buildCombinedSection(Map<Collection, List<IndexableObject>> newItemsByCollection,
182+
Map<Collection, List<IndexableObject>> modifiedItemsByCollection,
183+
Map<Collection, String> collectionToCommunity,
184+
Locale locale) {
185+
if (newItemsByCollection.isEmpty() && modifiedItemsByCollection.isEmpty()) {
79186
return EMPTY;
80187
}
81-
try {
82-
ByteArrayOutputStream out = new ByteArrayOutputStream();
83-
out.write("\n".getBytes(UTF_8));
84-
for (IndexableObject indexableObject : indexableObjects) {
85-
out.write("\n".getBytes(UTF_8));
86-
Item item = (Item) indexableObject.getIndexedObject();
87-
String entityType = itemService.getEntityTypeLabel(item);
88-
Optional.ofNullable(entityType2Disseminator.get(entityType))
89-
.orElseGet(() -> entityType2Disseminator.get("Item"))
90-
.disseminate(context, item, out);
188+
189+
StringBuilder sb = new StringBuilder();
190+
Set<Collection> allCollections = new HashSet<>();
191+
allCollections.addAll(newItemsByCollection.keySet());
192+
allCollections.addAll(modifiedItemsByCollection.keySet());
193+
194+
for (Collection collection : allCollections) {
195+
String header = buildCollectionHeader(collection, collectionToCommunity, locale);
196+
sb.append(header);
197+
198+
List<IndexableObject> newItems = newItemsByCollection.get(collection);
199+
List<IndexableObject> modifiedItems = modifiedItemsByCollection.get(collection);
200+
if (newItems != null && !newItems.isEmpty()) {
201+
sb.append(buildSectionHeader(I18nUtil.getMessage(NEW_ITEMS_LABEL_KEY, locale), newItems.size()));
202+
sb.append(buildItemsBlock(newItems));
203+
}
204+
if (modifiedItems != null && !modifiedItems.isEmpty()) {
205+
sb.append(buildSectionHeader(I18nUtil.getMessage(MODIFIED_ITEMS_LABEL_KEY, locale),
206+
modifiedItems.size()));
207+
sb.append(buildItemsBlock(modifiedItems));
208+
}
209+
sb.append(LINE_SEPARATOR);
210+
}
211+
return sb.toString();
212+
}
213+
214+
private String buildCollectionHeader(Collection collection, Map<Collection, String> collectionToCommunity,
215+
Locale locale) {
216+
String name = collection.getName();
217+
String communityNote = "";
218+
if (collectionToCommunity != null && collectionToCommunity.containsKey(collection)) {
219+
communityNote = I18nUtil.getMessage(COMMUNITY_NOTE_PREFIX_KEY, locale)
220+
+ collectionToCommunity.get(collection)
221+
+ I18nUtil.getMessage(COMMUNITY_NOTE_SUFFIX_KEY, locale);
222+
}
223+
String fullHeader = name + communityNote + ":" + LINE_SEPARATOR;
224+
String underline = "-".repeat(Math.max(0, name.length() + communityNote.length())) + LINE_SEPARATOR;
225+
return fullHeader + underline;
226+
}
227+
228+
private String buildSectionHeader(String label, int count) {
229+
StringBuilder sb = new StringBuilder();
230+
sb.append(" ").append(label).append(" (").append(count).append("):")
231+
.append(LINE_SEPARATOR).append(LINE_SEPARATOR);
232+
return sb.toString();
233+
}
234+
235+
private String buildItemsBlock(List<IndexableObject> items) {
236+
StringBuilder sb = new StringBuilder();
237+
for (IndexableObject obj : items) {
238+
Item item = (Item) obj.getIndexedObject();
239+
sb.append(formatItem(item)).append(LINE_SEPARATOR).append(LINE_SEPARATOR);
240+
}
241+
return sb.toString();
242+
}
243+
244+
private String formatItem(Item item) {
245+
String entityType = itemService.getEntityTypeLabel(item);
246+
SubscriptionDsoMetadataForEmailCompose crosswalk = entityType2Disseminator.get(entityType);
247+
if (crosswalk == null) {
248+
crosswalk = entityType2Disseminator.get("Item"); // fallback
249+
}
250+
if (crosswalk == null) {
251+
throw new IllegalStateException("No crosswalk found for entity type: " + entityType);
252+
}
253+
StringBuilder sb = new StringBuilder();
254+
for (ImmutablePair<String, String> mdPair: crosswalk.getMetadata()) {
255+
String field = mdPair.getLeft();
256+
String label = mdPair.getRight();
257+
List<String> values = getAllMetadata(item, field);
258+
if (!values.isEmpty()) {
259+
addIfNotBlank(sb, label + ": ", values);
260+
}
261+
}
262+
return sb.toString();
263+
}
264+
265+
private void addIfNotBlank(StringBuilder sb, String label, List<String> values) {
266+
if (values != null && !values.isEmpty()) {
267+
List<String> nonBlankValues = values.stream()
268+
.filter(StringUtils::isNotBlank)
269+
.collect(toList());
270+
271+
if (!nonBlankValues.isEmpty()) {
272+
String joined = nonBlankValues.stream()
273+
.limit(MAX_METADATA_VALUES)
274+
.collect(joining(", "));
275+
sb.append("\t").append(label).append(joined);
276+
if (nonBlankValues.size() > MAX_METADATA_VALUES) {
277+
sb.append(", ...");
278+
}
279+
sb.append(LINE_SEPARATOR);
91280
}
92-
out.close();
93-
return out.toString();
94-
} catch (Exception e) {
95-
log.error(e.getMessage(), e);
96281
}
97-
return EMPTY;
98282
}
99283

100-
public void setEntityType2Disseminator(Map<String, StreamDisseminationCrosswalk> entityType2Disseminator) {
284+
private List<String> getAllMetadata(Item item, String field) {
285+
String[] fieldParts = field.split("\\.");
286+
String schema = fieldParts[0];
287+
String element = fieldParts[1];
288+
String qualifier = fieldParts.length > 2 ? fieldParts[2] : null;
289+
return itemService.getMetadata(item, schema, element, qualifier, Item.ANY)
290+
.stream()
291+
.map(MetadataValue::getValue)
292+
.filter(Objects::nonNull)
293+
.collect(toList());
294+
}
295+
296+
public void setEntityType2Disseminator(Map<String, SubscriptionDsoMetadataForEmailCompose>
297+
entityType2Disseminator) {
101298
this.entityType2Disseminator = entityType2Disseminator;
102299
}
103300

0 commit comments

Comments
 (0)