diff --git a/fireflyframework-notifications-core/pom.xml b/fireflyframework-notifications-core/pom.xml index 0196fcc..adf37c0 100644 --- a/fireflyframework-notifications-core/pom.xml +++ b/fireflyframework-notifications-core/pom.xml @@ -37,6 +37,13 @@ spring-boot-starter-webflux + + + org.freemarker + freemarker + true + + org.projectlombok diff --git a/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/email/v1/EmailService.java b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/email/v1/EmailService.java index 6be488a..d071f52 100644 --- a/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/email/v1/EmailService.java +++ b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/email/v1/EmailService.java @@ -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 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 sendTemplateEmail(EmailTemplateRequestDTO request) { + return Mono.error(new UnsupportedOperationException( + "Template email not supported. Configure a NotificationTemplateEngine bean.")); + } } \ No newline at end of file diff --git a/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/email/v1/EmailServiceImpl.java b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/email/v1/EmailServiceImpl.java index 7739133..78ee428 100644 --- a/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/email/v1/EmailServiceImpl.java +++ b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/email/v1/EmailServiceImpl.java @@ -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 sendEmail(EmailRequestDTO request) { return emailProvider.sendEmail(request); } + + @Override + public Mono 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())); + } } \ No newline at end of file diff --git a/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/preferences/InMemoryNotificationPreferenceService.java b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/preferences/InMemoryNotificationPreferenceService.java new file mode 100644 index 0000000..11f9296 --- /dev/null +++ b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/preferences/InMemoryNotificationPreferenceService.java @@ -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}. + * + *

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 store = new ConcurrentHashMap<>(); + + @Override + public Mono 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 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 isChannelEnabled(String userId, String channel) { + return getPreferences(userId) + .map(prefs -> prefs.isChannelEnabled(channel)); + } +} diff --git a/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/preferences/NotificationPreferenceService.java b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/preferences/NotificationPreferenceService.java new file mode 100644 index 0000000..e94ef66 --- /dev/null +++ b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/preferences/NotificationPreferenceService.java @@ -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. + * + *

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 getPreferences(String userId); + + /** + * Update notification preferences for a user. + */ + Mono updatePreferences(String userId, NotificationPreferenceDTO preferences); + + /** + * Check if a specific channel is enabled for a user. + */ + Mono isChannelEnabled(String userId, String channel); +} diff --git a/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/template/FreemarkerNotificationTemplateEngine.java b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/template/FreemarkerNotificationTemplateEngine.java new file mode 100644 index 0000000..ea409c6 --- /dev/null +++ b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/template/FreemarkerNotificationTemplateEngine.java @@ -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}. + * + *

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 render(String templateId, Map 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 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; + } +} diff --git a/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/template/NotificationTemplateEngine.java b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/template/NotificationTemplateEngine.java new file mode 100644 index 0000000..7e6c142 --- /dev/null +++ b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/core/services/template/NotificationTemplateEngine.java @@ -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. + * + *

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 render(String templateId, Map variables); +} diff --git a/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/interfaces/dtos/email/v1/EmailTemplateRequestDTO.java b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/interfaces/dtos/email/v1/EmailTemplateRequestDTO.java new file mode 100644 index 0000000..609964b --- /dev/null +++ b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/interfaces/dtos/email/v1/EmailTemplateRequestDTO.java @@ -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. + * + *

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 templateVariables = new HashMap<>(); + + private String from; + private String to; + + @Builder.Default + private List cc = new ArrayList<>(); + + @Builder.Default + private List bcc = new ArrayList<>(); + + private String subject; +} diff --git a/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/interfaces/dtos/preferences/NotificationPreferenceDTO.java b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/interfaces/dtos/preferences/NotificationPreferenceDTO.java new file mode 100644 index 0000000..d32210d --- /dev/null +++ b/fireflyframework-notifications-core/src/main/java/org/fireflyframework/notifications/interfaces/dtos/preferences/NotificationPreferenceDTO.java @@ -0,0 +1,65 @@ +/* + * 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.preferences; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.HashMap; +import java.util.Map; + +/** + * DTO representing a user's notification channel preferences. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationPreferenceDTO { + + private String userId; + + @Builder.Default + private boolean emailEnabled = true; + + @Builder.Default + private boolean smsEnabled = true; + + @Builder.Default + private boolean pushEnabled = true; + + @Builder.Default + private Map channels = new HashMap<>(); + + /** + * Check if a specific channel is enabled. + * Falls back to the top-level channel toggle if no specific override exists. + */ + public boolean isChannelEnabled(String channel) { + if (channels.containsKey(channel)) { + return channels.get(channel); + } + return switch (channel.toLowerCase()) { + case "email" -> emailEnabled; + case "sms" -> smsEnabled; + case "push" -> pushEnabled; + default -> true; + }; + } +}