Skip to content

Commit 183d91f

Browse files
committed
feat: add template support and notification preferences
- Add EmailTemplateRequestDTO for template-based emails - Add TemplateEngine interface with FreemarkerTemplateEngine implementation - Add NotificationPreferenceService for user channel preferences - Add preference checking before notification delivery
1 parent 8447a25 commit 183d91f

9 files changed

Lines changed: 416 additions & 0 deletions

File tree

fireflyframework-notifications-core/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@
3737
<artifactId>spring-boot-starter-webflux</artifactId>
3838
</dependency>
3939

40+
<!-- FreeMarker (optional — for template-based notifications) -->
41+
<dependency>
42+
<groupId>org.freemarker</groupId>
43+
<artifactId>freemarker</artifactId>
44+
<optional>true</optional>
45+
</dependency>
46+
4047
<!-- Lombok -->
4148
<dependency>
4249
<groupId>org.projectlombok</groupId>

fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/email/v1/EmailService.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,20 @@
1919

2020
import org.fireflyframework.notifications.interfaces.dtos.email.v1.EmailRequestDTO;
2121
import org.fireflyframework.notifications.interfaces.dtos.email.v1.EmailResponseDTO;
22+
import org.fireflyframework.notifications.interfaces.dtos.email.v1.EmailTemplateRequestDTO;
2223
import reactor.core.publisher.Mono;
2324

2425
public interface EmailService {
2526
Mono<EmailResponseDTO> sendEmail(EmailRequestDTO request);
27+
28+
/**
29+
* Send a templated email. The template is rendered before sending.
30+
*
31+
* @param request the template email request (templateId + variables + recipients)
32+
* @return a Mono emitting the email response
33+
*/
34+
default Mono<EmailResponseDTO> sendTemplateEmail(EmailTemplateRequestDTO request) {
35+
return Mono.error(new UnsupportedOperationException(
36+
"Template email not supported. Configure a NotificationTemplateEngine bean."));
37+
}
2638
}

fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/email/v1/EmailServiceImpl.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,51 @@
1717

1818
package org.fireflyframework.notifications.core.services.email.v1;
1919

20+
import lombok.extern.slf4j.Slf4j;
21+
import org.fireflyframework.notifications.core.services.template.NotificationTemplateEngine;
2022
import org.fireflyframework.notifications.interfaces.dtos.email.v1.EmailRequestDTO;
2123
import org.fireflyframework.notifications.interfaces.dtos.email.v1.EmailResponseDTO;
24+
import org.fireflyframework.notifications.interfaces.dtos.email.v1.EmailTemplateRequestDTO;
2225
import org.fireflyframework.notifications.interfaces.interfaces.providers.email.v1.EmailProvider;
2326
import org.springframework.beans.factory.annotation.Autowired;
2427
import org.springframework.stereotype.Service;
2528
import reactor.core.publisher.Mono;
2629

2730
@Service
31+
@Slf4j
2832
public class EmailServiceImpl implements EmailService {
2933

3034
@Autowired
3135
private EmailProvider emailProvider;
3236

37+
@Autowired(required = false)
38+
private NotificationTemplateEngine templateEngine;
39+
3340
@Override
3441
public Mono<EmailResponseDTO> sendEmail(EmailRequestDTO request) {
3542
return emailProvider.sendEmail(request);
3643
}
44+
45+
@Override
46+
public Mono<EmailResponseDTO> sendTemplateEmail(EmailTemplateRequestDTO request) {
47+
if (templateEngine == null) {
48+
return Mono.error(new UnsupportedOperationException(
49+
"Template email not supported. Configure a NotificationTemplateEngine bean."));
50+
}
51+
52+
return templateEngine.render(request.getTemplateId(), request.getTemplateVariables())
53+
.flatMap(renderedHtml -> {
54+
EmailRequestDTO emailRequest = EmailRequestDTO.builder()
55+
.from(request.getFrom())
56+
.to(request.getTo())
57+
.cc(request.getCc())
58+
.bcc(request.getBcc())
59+
.subject(request.getSubject())
60+
.html(renderedHtml)
61+
.build();
62+
return emailProvider.sendEmail(emailRequest);
63+
})
64+
.doOnError(e -> log.error("Failed to send template email '{}': {}",
65+
request.getTemplateId(), e.getMessage()));
66+
}
3767
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright 2024-2026 Firefly Software Solutions Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.fireflyframework.notifications.core.services.preferences;
18+
19+
import lombok.extern.slf4j.Slf4j;
20+
import org.fireflyframework.notifications.interfaces.dtos.preferences.NotificationPreferenceDTO;
21+
import reactor.core.publisher.Mono;
22+
23+
import java.util.concurrent.ConcurrentHashMap;
24+
25+
/**
26+
* In-memory implementation of {@link NotificationPreferenceService}.
27+
*
28+
* <p>Suitable for development, testing, and single-instance deployments.
29+
* For production multi-instance deployments, use a persistent-backed implementation.
30+
*/
31+
@Slf4j
32+
public class InMemoryNotificationPreferenceService implements NotificationPreferenceService {
33+
34+
private final ConcurrentHashMap<String, NotificationPreferenceDTO> store = new ConcurrentHashMap<>();
35+
36+
@Override
37+
public Mono<NotificationPreferenceDTO> getPreferences(String userId) {
38+
return Mono.justOrEmpty(store.get(userId))
39+
.switchIfEmpty(Mono.just(NotificationPreferenceDTO.builder()
40+
.userId(userId)
41+
.emailEnabled(true)
42+
.smsEnabled(true)
43+
.pushEnabled(true)
44+
.build()));
45+
}
46+
47+
@Override
48+
public Mono<NotificationPreferenceDTO> updatePreferences(String userId, NotificationPreferenceDTO preferences) {
49+
preferences.setUserId(userId);
50+
store.put(userId, preferences);
51+
log.debug("Updated notification preferences for user: {}", userId);
52+
return Mono.just(preferences);
53+
}
54+
55+
@Override
56+
public Mono<Boolean> isChannelEnabled(String userId, String channel) {
57+
return getPreferences(userId)
58+
.map(prefs -> prefs.isChannelEnabled(channel));
59+
}
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright 2024-2026 Firefly Software Solutions Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.fireflyframework.notifications.core.services.preferences;
18+
19+
import org.fireflyframework.notifications.interfaces.dtos.preferences.NotificationPreferenceDTO;
20+
import reactor.core.publisher.Mono;
21+
22+
/**
23+
* Service for managing user notification preferences.
24+
*
25+
* <p>Implementations may store preferences in memory, R2DBC, or a cache backend.
26+
* The default in-memory implementation is provided for development and testing.
27+
*/
28+
public interface NotificationPreferenceService {
29+
30+
/**
31+
* Get notification preferences for a user.
32+
* Returns default (all channels enabled) if no preferences are stored.
33+
*/
34+
Mono<NotificationPreferenceDTO> getPreferences(String userId);
35+
36+
/**
37+
* Update notification preferences for a user.
38+
*/
39+
Mono<NotificationPreferenceDTO> updatePreferences(String userId, NotificationPreferenceDTO preferences);
40+
41+
/**
42+
* Check if a specific channel is enabled for a user.
43+
*/
44+
Mono<Boolean> isChannelEnabled(String userId, String channel);
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright 2024-2026 Firefly Software Solutions Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.fireflyframework.notifications.core.services.template;
18+
19+
import freemarker.cache.ClassTemplateLoader;
20+
import freemarker.cache.FileTemplateLoader;
21+
import freemarker.cache.MultiTemplateLoader;
22+
import freemarker.cache.TemplateLoader;
23+
import freemarker.template.Configuration;
24+
import freemarker.template.Template;
25+
import freemarker.template.TemplateExceptionHandler;
26+
import lombok.extern.slf4j.Slf4j;
27+
import reactor.core.publisher.Mono;
28+
import reactor.core.scheduler.Schedulers;
29+
30+
import java.io.File;
31+
import java.io.IOException;
32+
import java.io.StringWriter;
33+
import java.nio.charset.StandardCharsets;
34+
import java.util.ArrayList;
35+
import java.util.List;
36+
import java.util.Locale;
37+
import java.util.Map;
38+
39+
/**
40+
* FreeMarker-based implementation of {@link NotificationTemplateEngine}.
41+
*
42+
* <p>Loads templates from a configurable classpath prefix (default: {@code /notification-templates})
43+
* and an optional filesystem directory. Template files use the {@code .ftl} extension by convention.
44+
*/
45+
@Slf4j
46+
public class FreemarkerNotificationTemplateEngine implements NotificationTemplateEngine {
47+
48+
private final Configuration configuration;
49+
50+
public FreemarkerNotificationTemplateEngine(String classpathPrefix, String filesystemDir) {
51+
this.configuration = buildConfiguration(classpathPrefix, filesystemDir);
52+
log.info("FreemarkerNotificationTemplateEngine initialized (classpath: {}, filesystem: {})",
53+
classpathPrefix, filesystemDir);
54+
}
55+
56+
public FreemarkerNotificationTemplateEngine() {
57+
this("/notification-templates", null);
58+
}
59+
60+
@Override
61+
public Mono<String> render(String templateId, Map<String, Object> variables) {
62+
return Mono.fromCallable(() -> {
63+
String templateName = templateId.endsWith(".ftl") ? templateId : templateId + ".ftl";
64+
Template template = configuration.getTemplate(templateName);
65+
StringWriter writer = new StringWriter();
66+
template.process(variables != null ? variables : Map.of(), writer);
67+
return writer.toString();
68+
})
69+
.subscribeOn(Schedulers.boundedElastic())
70+
.doOnError(e -> log.error("Failed to render template '{}': {}", templateId, e.getMessage()));
71+
}
72+
73+
private Configuration buildConfiguration(String classpathPrefix, String filesystemDir) {
74+
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
75+
cfg.setDefaultEncoding(StandardCharsets.UTF_8.name());
76+
cfg.setLocale(Locale.US);
77+
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
78+
79+
List<TemplateLoader> loaders = new ArrayList<>();
80+
loaders.add(new ClassTemplateLoader(getClass().getClassLoader(),
81+
classpathPrefix != null ? classpathPrefix : "/notification-templates"));
82+
83+
if (filesystemDir != null) {
84+
try {
85+
File dir = new File(filesystemDir);
86+
if (dir.isDirectory()) {
87+
loaders.add(new FileTemplateLoader(dir));
88+
}
89+
} catch (IOException e) {
90+
log.warn("Could not configure filesystem template loader for '{}': {}", filesystemDir, e.getMessage());
91+
}
92+
}
93+
94+
if (loaders.size() == 1) {
95+
cfg.setTemplateLoader(loaders.get(0));
96+
} else {
97+
cfg.setTemplateLoader(new MultiTemplateLoader(loaders.toArray(new TemplateLoader[0])));
98+
}
99+
100+
return cfg;
101+
}
102+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2024-2026 Firefly Software Solutions Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.fireflyframework.notifications.core.services.template;
18+
19+
import reactor.core.publisher.Mono;
20+
21+
import java.util.Map;
22+
23+
/**
24+
* Reactive template engine for rendering notification templates.
25+
*
26+
* <p>Templates are resolved by ID and rendered with variable substitution.
27+
* Implementations may use FreeMarker, Mustache, Thymeleaf, or any other engine.
28+
*/
29+
public interface NotificationTemplateEngine {
30+
31+
/**
32+
* Render a template by its ID with the given variables.
33+
*
34+
* @param templateId the template identifier (e.g., "welcome-email", "password-reset")
35+
* @param variables the variable map for template substitution
36+
* @return a Mono emitting the rendered content (typically HTML)
37+
*/
38+
Mono<String> render(String templateId, Map<String, Object> variables);
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2024-2026 Firefly Software Solutions Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.fireflyframework.notifications.interfaces.dtos.email.v1;
18+
19+
import lombok.AllArgsConstructor;
20+
import lombok.Builder;
21+
import lombok.Data;
22+
import lombok.NoArgsConstructor;
23+
24+
import java.util.ArrayList;
25+
import java.util.HashMap;
26+
import java.util.List;
27+
import java.util.Map;
28+
29+
/**
30+
* Request DTO for sending templated emails.
31+
*
32+
* <p>The {@code templateId} identifies a template (e.g., "welcome-email"),
33+
* and {@code templateVariables} are substituted into the template before sending.
34+
*/
35+
@Data
36+
@Builder
37+
@NoArgsConstructor
38+
@AllArgsConstructor
39+
public class EmailTemplateRequestDTO {
40+
41+
private String templateId;
42+
43+
@Builder.Default
44+
private Map<String, Object> templateVariables = new HashMap<>();
45+
46+
private String from;
47+
private String to;
48+
49+
@Builder.Default
50+
private List<String> cc = new ArrayList<>();
51+
52+
@Builder.Default
53+
private List<String> bcc = new ArrayList<>();
54+
55+
private String subject;
56+
}

0 commit comments

Comments
 (0)