Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions fireflyframework-notifications-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

<!-- FreeMarker (optional — for template-based notifications) -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<optional>true</optional>
</dependency>

<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,20 @@

import org.fireflyframework.notifications.interfaces.dtos.email.v1.EmailRequestDTO;
import org.fireflyframework.notifications.interfaces.dtos.email.v1.EmailResponseDTO;
import org.fireflyframework.notifications.interfaces.dtos.email.v1.EmailTemplateRequestDTO;
import reactor.core.publisher.Mono;

public interface EmailService {
Mono<EmailResponseDTO> sendEmail(EmailRequestDTO request);

/**
* Send a templated email. The template is rendered before sending.
*
* @param request the template email request (templateId + variables + recipients)
* @return a Mono emitting the email response
*/
default Mono<EmailResponseDTO> sendTemplateEmail(EmailTemplateRequestDTO request) {
return Mono.error(new UnsupportedOperationException(
"Template email not supported. Configure a NotificationTemplateEngine bean."));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,51 @@

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

import lombok.extern.slf4j.Slf4j;
import org.fireflyframework.notifications.core.services.template.NotificationTemplateEngine;
import org.fireflyframework.notifications.interfaces.dtos.email.v1.EmailRequestDTO;
import org.fireflyframework.notifications.interfaces.dtos.email.v1.EmailResponseDTO;
import org.fireflyframework.notifications.interfaces.dtos.email.v1.EmailTemplateRequestDTO;
import org.fireflyframework.notifications.interfaces.interfaces.providers.email.v1.EmailProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;

@Service
@Slf4j
public class EmailServiceImpl implements EmailService {

@Autowired
private EmailProvider emailProvider;

@Autowired(required = false)
private NotificationTemplateEngine templateEngine;

@Override
public Mono<EmailResponseDTO> sendEmail(EmailRequestDTO request) {
return emailProvider.sendEmail(request);
}

@Override
public Mono<EmailResponseDTO> sendTemplateEmail(EmailTemplateRequestDTO request) {
if (templateEngine == null) {
return Mono.error(new UnsupportedOperationException(
"Template email not supported. Configure a NotificationTemplateEngine bean."));
}

return templateEngine.render(request.getTemplateId(), request.getTemplateVariables())
.flatMap(renderedHtml -> {
EmailRequestDTO emailRequest = EmailRequestDTO.builder()
.from(request.getFrom())
.to(request.getTo())
.cc(request.getCc())
.bcc(request.getBcc())
.subject(request.getSubject())
.html(renderedHtml)
.build();
return emailProvider.sendEmail(emailRequest);
})
.doOnError(e -> log.error("Failed to send template email '{}': {}",
request.getTemplateId(), e.getMessage()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2024-2026 Firefly Software Solutions Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.fireflyframework.notifications.core.services.preferences;

import lombok.extern.slf4j.Slf4j;
import org.fireflyframework.notifications.interfaces.dtos.preferences.NotificationPreferenceDTO;
import reactor.core.publisher.Mono;

import java.util.concurrent.ConcurrentHashMap;

/**
* In-memory implementation of {@link NotificationPreferenceService}.
*
* <p>Suitable for development, testing, and single-instance deployments.
* For production multi-instance deployments, use a persistent-backed implementation.
*/
@Slf4j
public class InMemoryNotificationPreferenceService implements NotificationPreferenceService {

private final ConcurrentHashMap<String, NotificationPreferenceDTO> store = new ConcurrentHashMap<>();

@Override
public Mono<NotificationPreferenceDTO> getPreferences(String userId) {
return Mono.justOrEmpty(store.get(userId))
.switchIfEmpty(Mono.just(NotificationPreferenceDTO.builder()
.userId(userId)
.emailEnabled(true)
.smsEnabled(true)
.pushEnabled(true)
.build()));
}

@Override
public Mono<NotificationPreferenceDTO> updatePreferences(String userId, NotificationPreferenceDTO preferences) {
preferences.setUserId(userId);
store.put(userId, preferences);
log.debug("Updated notification preferences for user: {}", userId);
return Mono.just(preferences);
}

@Override
public Mono<Boolean> isChannelEnabled(String userId, String channel) {
return getPreferences(userId)
.map(prefs -> prefs.isChannelEnabled(channel));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2024-2026 Firefly Software Solutions Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.fireflyframework.notifications.core.services.preferences;

import org.fireflyframework.notifications.interfaces.dtos.preferences.NotificationPreferenceDTO;
import reactor.core.publisher.Mono;

/**
* Service for managing user notification preferences.
*
* <p>Implementations may store preferences in memory, R2DBC, or a cache backend.
* The default in-memory implementation is provided for development and testing.
*/
public interface NotificationPreferenceService {

/**
* Get notification preferences for a user.
* Returns default (all channels enabled) if no preferences are stored.
*/
Mono<NotificationPreferenceDTO> getPreferences(String userId);

/**
* Update notification preferences for a user.
*/
Mono<NotificationPreferenceDTO> updatePreferences(String userId, NotificationPreferenceDTO preferences);

/**
* Check if a specific channel is enabled for a user.
*/
Mono<Boolean> isChannelEnabled(String userId, String channel);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Copyright 2024-2026 Firefly Software Solutions Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.fireflyframework.notifications.core.services.template;

import freemarker.cache.ClassTemplateLoader;
import freemarker.cache.FileTemplateLoader;
import freemarker.cache.MultiTemplateLoader;
import freemarker.cache.TemplateLoader;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateExceptionHandler;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

import java.io.File;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/**
* FreeMarker-based implementation of {@link NotificationTemplateEngine}.
*
* <p>Loads templates from a configurable classpath prefix (default: {@code /notification-templates})
* and an optional filesystem directory. Template files use the {@code .ftl} extension by convention.
*/
@Slf4j
public class FreemarkerNotificationTemplateEngine implements NotificationTemplateEngine {

private final Configuration configuration;

public FreemarkerNotificationTemplateEngine(String classpathPrefix, String filesystemDir) {
this.configuration = buildConfiguration(classpathPrefix, filesystemDir);
log.info("FreemarkerNotificationTemplateEngine initialized (classpath: {}, filesystem: {})",
classpathPrefix, filesystemDir);
}

public FreemarkerNotificationTemplateEngine() {
this("/notification-templates", null);
}

@Override
public Mono<String> render(String templateId, Map<String, Object> variables) {
return Mono.fromCallable(() -> {
String templateName = templateId.endsWith(".ftl") ? templateId : templateId + ".ftl";
Template template = configuration.getTemplate(templateName);
StringWriter writer = new StringWriter();
template.process(variables != null ? variables : Map.of(), writer);
return writer.toString();
})
.subscribeOn(Schedulers.boundedElastic())
.doOnError(e -> log.error("Failed to render template '{}': {}", templateId, e.getMessage()));
}

private Configuration buildConfiguration(String classpathPrefix, String filesystemDir) {
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setDefaultEncoding(StandardCharsets.UTF_8.name());
cfg.setLocale(Locale.US);
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);

List<TemplateLoader> loaders = new ArrayList<>();
loaders.add(new ClassTemplateLoader(getClass().getClassLoader(),
classpathPrefix != null ? classpathPrefix : "/notification-templates"));

if (filesystemDir != null) {
try {
File dir = new File(filesystemDir);
if (dir.isDirectory()) {
loaders.add(new FileTemplateLoader(dir));
}
} catch (IOException e) {
log.warn("Could not configure filesystem template loader for '{}': {}", filesystemDir, e.getMessage());
}
}

if (loaders.size() == 1) {
cfg.setTemplateLoader(loaders.get(0));
} else {
cfg.setTemplateLoader(new MultiTemplateLoader(loaders.toArray(new TemplateLoader[0])));
}

return cfg;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright 2024-2026 Firefly Software Solutions Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.fireflyframework.notifications.core.services.template;

import reactor.core.publisher.Mono;

import java.util.Map;

/**
* Reactive template engine for rendering notification templates.
*
* <p>Templates are resolved by ID and rendered with variable substitution.
* Implementations may use FreeMarker, Mustache, Thymeleaf, or any other engine.
*/
public interface NotificationTemplateEngine {

/**
* Render a template by its ID with the given variables.
*
* @param templateId the template identifier (e.g., "welcome-email", "password-reset")
* @param variables the variable map for template substitution
* @return a Mono emitting the rendered content (typically HTML)
*/
Mono<String> render(String templateId, Map<String, Object> variables);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2024-2026 Firefly Software Solutions Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.fireflyframework.notifications.interfaces.dtos.email.v1;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
* Request DTO for sending templated emails.
*
* <p>The {@code templateId} identifies a template (e.g., "welcome-email"),
* and {@code templateVariables} are substituted into the template before sending.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EmailTemplateRequestDTO {

private String templateId;

@Builder.Default
private Map<String, Object> templateVariables = new HashMap<>();

private String from;
private String to;

@Builder.Default
private List<String> cc = new ArrayList<>();

@Builder.Default
private List<String> bcc = new ArrayList<>();

private String subject;
}
Loading