From 985777ebcc26f0c2e27a9ae3b6cf19a9fe398abc Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Fri, 5 Dec 2025 14:58:03 -0300 Subject: [PATCH] Improvements to the notify step Closes #44708 Signed-off-by: Pedro Igor --- .../DefaultWorkflowExecutionContext.java | 5 + .../workflow/DefaultWorkflowProvider.java | 15 -- .../models/workflow/RunWorkflowTask.java | 12 +- .../keycloak/models/workflow/Workflow.java | 2 +- .../workflow/WorkflowExecutionContext.java | 7 + .../models/workflow/WorkflowStep.java | 25 ++- .../models/workflow/WorkflowStepProvider.java | 22 +++ .../keycloak/models/workflow/Workflows.java | 16 ++ .../workflow/DeleteUserStepProvider.java | 10 ++ .../workflow/DisableUserStepProvider.java | 10 ++ .../workflow/NotifyUserStepProvider.java | 151 +++++++++--------- .../model/workflow/NotificationStepTest.java | 105 ++++++++++++ .../UserSessionRefreshTimeWorkflowTest.java | 8 +- .../workflow/WorkflowManagementTest.java | 4 +- .../base/email/html/workflow-notification.ftl | 29 ++-- .../base/email/text/workflow-notification.ftl | 10 +- 16 files changed, 305 insertions(+), 126 deletions(-) create mode 100644 tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/NotificationStepTest.java diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowExecutionContext.java b/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowExecutionContext.java index fe351d0eac9..3a35eae4a40 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowExecutionContext.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowExecutionContext.java @@ -77,6 +77,11 @@ final class DefaultWorkflowExecutionContext implements WorkflowExecutionContext return event; } + @Override + public WorkflowStep getNextStep() { + return workflow.getSteps(currentStep.getId()).skip(1).findFirst().orElse(null); + } + String getExecutionId() { return this.executionId; } diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowProvider.java b/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowProvider.java index a05623ec7d1..be269ffa46b 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowProvider.java @@ -216,10 +216,6 @@ public class DefaultWorkflowProvider implements WorkflowProvider { public void close() { } - WorkflowStepProvider getStepProvider(WorkflowStep step) { - return getStepProviderFactory(step).create(session, realm.getComponent(step.getId())); - } - private ComponentModel getWorkflowComponent(String id) { ComponentModel component = realm.getComponent(id); @@ -238,17 +234,6 @@ public class DefaultWorkflowProvider implements WorkflowProvider { return (WorkflowProvider) factory.create(session, realm.getComponent(workflow.getId())); } - private WorkflowStepProviderFactory getStepProviderFactory(WorkflowStep step) { - WorkflowStepProviderFactory factory = (WorkflowStepProviderFactory) session - .getKeycloakSessionFactory().getProviderFactory(WorkflowStepProvider.class, step.getProviderId()); - - if (factory == null) { - throw new WorkflowInvalidStateException("Step not found: " + step.getProviderId()); - } - - return factory; - } - private void processEvent(Stream workflows, WorkflowEvent event) { Map scheduledSteps = stateProvider.getScheduledStepsByResource(event.getResourceId()) .collect(Collectors.toMap(ScheduledStep::workflowId, Function.identity())); diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/RunWorkflowTask.java b/model/jpa/src/main/java/org/keycloak/models/workflow/RunWorkflowTask.java index 324d1af88c5..8f19662e3b2 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/RunWorkflowTask.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/RunWorkflowTask.java @@ -7,6 +7,8 @@ import org.keycloak.models.KeycloakSession; import org.jboss.logging.Logger; +import static org.keycloak.models.workflow.Workflows.getStepProvider; + class RunWorkflowTask extends WorkflowTransactionalTask { private static final Logger log = Logger.getLogger(RunWorkflowTask.class); @@ -28,7 +30,7 @@ class RunWorkflowTask extends WorkflowTransactionalTask { if (currentStep != null) { // we are resuming from a scheduled step - run it and then continue with the rest of the workflow - runWorkflowStep(provider, context); + runWorkflowStep(session, provider, context); } List stepsToRun = workflow.getSteps() @@ -38,14 +40,14 @@ class RunWorkflowTask extends WorkflowTransactionalTask { for (WorkflowStep step : stepsToRun) { if (DurationConverter.isPositiveDuration(step.getAfter())) { // If a step has a time defined, schedule it and stop processing the other steps of workflow - log.debugf("Scheduling step %s to run in %d ms for resource %s (execution id: %s)", + log.debugf("Scheduling step %s to run in %s ms for resource %s (execution id: %s)", step.getProviderId(), step.getAfter(), resourceId, executionId); stateProvider.scheduleStep(workflow, step, resourceId, executionId); return; } else { // Otherwise, run the step right away context.setCurrentStep(step); - runWorkflowStep(provider, context); + runWorkflowStep(session, provider, context); } } if (context.isRestarted()) { @@ -59,13 +61,13 @@ class RunWorkflowTask extends WorkflowTransactionalTask { stateProvider.remove(executionId); } - private void runWorkflowStep(DefaultWorkflowProvider provider, DefaultWorkflowExecutionContext context) { + private void runWorkflowStep(KeycloakSession session, DefaultWorkflowProvider provider, DefaultWorkflowExecutionContext context) { String executionId = context.getExecutionId(); WorkflowStep step = context.getCurrentStep(); String resourceId = context.getResourceId(); log.debugf("Running step %s on resource %s (execution id: %s)", step.getProviderId(), resourceId, executionId); try { - provider.getStepProvider(step).run(context); + getStepProvider(session, step).run(context); log.debugf("Step %s completed successfully (execution id: %s)", step.getProviderId(), executionId); } catch(WorkflowExecutionException e) { StringBuilder sb = new StringBuilder(); diff --git a/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflow.java b/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflow.java index 0f08cee7c43..5b2caff995f 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflow.java +++ b/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflow.java @@ -116,7 +116,7 @@ public class Workflow { public Stream getSteps() { return realm.getComponentsStream(getId(), WorkflowStepProvider.class.getName()) - .map(WorkflowStep::new).sorted(); + .map((c) -> new WorkflowStep(session, c)).sorted(); } /** diff --git a/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowExecutionContext.java b/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowExecutionContext.java index 5d7442e4915..0f06e6094f8 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowExecutionContext.java +++ b/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowExecutionContext.java @@ -19,4 +19,11 @@ public interface WorkflowExecutionContext { * @return the event bound to the current execution. */ WorkflowEvent getEvent(); + + /** + * Returns the next step to be executed in the workflow. + * + * @return the next workflow step + */ + WorkflowStep getNextStep(); } diff --git a/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStep.java b/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStep.java index 49492fc4a20..5b64bb0d39a 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStep.java +++ b/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStep.java @@ -21,12 +21,14 @@ import java.util.Objects; import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_AFTER; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_PRIORITY; public class WorkflowStep implements Comparable { + private KeycloakSession session; private String id; private final String providerId; private MultivaluedHashMap config; @@ -36,7 +38,8 @@ public class WorkflowStep implements Comparable { this.config = config; } - public WorkflowStep(ComponentModel model) { + public WorkflowStep(KeycloakSession session, ComponentModel model) { + this.session = session; this.id = model.getId(); this.providerId = model.getProviderId(); this.config = model.getConfig(); @@ -89,6 +92,26 @@ public class WorkflowStep implements Comparable { return getConfig().getFirst(CONFIG_AFTER); } + public String getNotificationSubject() { + if (session != null) { + WorkflowStepProvider provider = Workflows.getStepProvider(session, this); + if (provider != null) { + return provider.getNotificationSubject(); + } + } + return null; + } + + public String getNotificationMessage() { + if (session != null) { + WorkflowStepProvider provider = Workflows.getStepProvider(session, this); + if (provider != null) { + return provider.getNotificationMessage(); + } + } + return null; + } + @Override public int compareTo(WorkflowStep other) { return Integer.compare(this.getPriority(), other.getPriority()); diff --git a/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStepProvider.java b/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStepProvider.java index 44720d93b96..6a46496df98 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStepProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStepProvider.java @@ -21,6 +21,28 @@ import org.keycloak.provider.Provider; public interface WorkflowStepProvider extends Provider { + /** + * Run this workflow step. + * + * @param context the workflow execution context + */ void run(WorkflowExecutionContext context); + /** + * Returns the message or the text that should be used as the subject of the email when notifying the user about this step. + * + * @return the notification subject, or {@code null} if the default subject should be used + */ + default String getNotificationSubject() { + return null; + } + + /** + * Returns the message or the text that should be used as the body of the email when notifying the user about this step. + * + * @return the notification body, or {@code null} if the default subject should be used + */ + default String getNotificationMessage() { + return null; + } } diff --git a/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflows.java b/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflows.java index d51bff77a06..a2d8071a753 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflows.java +++ b/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflows.java @@ -2,6 +2,7 @@ package org.keycloak.models.workflow; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; public final class Workflows { @@ -20,4 +21,19 @@ public final class Workflows { return providerFactory; } + public static WorkflowStepProvider getStepProvider(KeycloakSession session, WorkflowStep step) { + RealmModel realm = session.getContext().getRealm(); + return getStepProviderFactory(session, step).create(session, realm.getComponent(step.getId())); + } + + private static WorkflowStepProviderFactory getStepProviderFactory(KeycloakSession session, WorkflowStep step) { + WorkflowStepProviderFactory factory = (WorkflowStepProviderFactory) session + .getKeycloakSessionFactory().getProviderFactory(WorkflowStepProvider.class, step.getProviderId()); + + if (factory == null) { + throw new WorkflowInvalidStateException("Step not found: " + step.getProviderId()); + } + + return factory; + } } diff --git a/services/src/main/java/org/keycloak/models/workflow/DeleteUserStepProvider.java b/services/src/main/java/org/keycloak/models/workflow/DeleteUserStepProvider.java index 294fe654ee7..4df2dda875d 100644 --- a/services/src/main/java/org/keycloak/models/workflow/DeleteUserStepProvider.java +++ b/services/src/main/java/org/keycloak/models/workflow/DeleteUserStepProvider.java @@ -70,4 +70,14 @@ public class DeleteUserStepProvider implements WorkflowStepProvider { userCache.evict(realm, user); } } + + @Override + public String getNotificationMessage() { + return "accountDeleteNotificationBody"; + } + + @Override + public String getNotificationSubject() { + return "accountDeleteNotificationSubject"; + } } diff --git a/services/src/main/java/org/keycloak/models/workflow/DisableUserStepProvider.java b/services/src/main/java/org/keycloak/models/workflow/DisableUserStepProvider.java index 23b96614b47..5d7bf0d4aff 100644 --- a/services/src/main/java/org/keycloak/models/workflow/DisableUserStepProvider.java +++ b/services/src/main/java/org/keycloak/models/workflow/DisableUserStepProvider.java @@ -48,4 +48,14 @@ public class DisableUserStepProvider implements WorkflowStepProvider { user.setEnabled(false); } } + + @Override + public String getNotificationMessage() { + return "accountDisableNotificationBody"; + } + + @Override + public String getNotificationSubject() { + return "accountDisableNotificationSubject"; + } } diff --git a/services/src/main/java/org/keycloak/models/workflow/NotifyUserStepProvider.java b/services/src/main/java/org/keycloak/models/workflow/NotifyUserStepProvider.java index df634207a5c..b78d4ccc9c5 100644 --- a/services/src/main/java/org/keycloak/models/workflow/NotifyUserStepProvider.java +++ b/services/src/main/java/org/keycloak/models/workflow/NotifyUserStepProvider.java @@ -17,12 +17,11 @@ package org.keycloak.models.workflow; -import java.time.Duration; import java.util.HashMap; -import java.util.List; import java.util.Map; import org.keycloak.common.util.DurationConverter; +import org.keycloak.common.util.StringPropertyReplacer.PropertyResolver; import org.keycloak.component.ComponentModel; import org.keycloak.email.EmailException; import org.keycloak.email.EmailTemplateProvider; @@ -32,15 +31,10 @@ import org.keycloak.models.UserModel; import org.jboss.logging.Logger; -import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_AFTER; +import static org.keycloak.common.util.StringPropertyReplacer.replaceProperties; public class NotifyUserStepProvider implements WorkflowStepProvider { - private static final String ACCOUNT_DISABLE_NOTIFICATION_SUBJECT = "accountDisableNotificationSubject"; - private static final String ACCOUNT_DELETE_NOTIFICATION_SUBJECT = "accountDeleteNotificationSubject"; - private static final String ACCOUNT_DISABLE_NOTIFICATION_BODY = "accountDisableNotificationBody"; - private static final String ACCOUNT_DELETE_NOTIFICATION_BODY = "accountDeleteNotificationBody"; - private final KeycloakSession session; private final ComponentModel stepModel; private final Logger log = Logger.getLogger(NotifyUserStepProvider.class); @@ -59,9 +53,9 @@ public class NotifyUserStepProvider implements WorkflowStepProvider { RealmModel realm = session.getContext().getRealm(); EmailTemplateProvider emailProvider = session.getProvider(EmailTemplateProvider.class).setRealm(realm); - String subjectKey = getSubjectKey(); + String subjectKey = getSubjectKey(context); String bodyTemplate = getBodyTemplate(); - Map bodyAttributes = getBodyAttributes(); + Map bodyAttributes = getBodyAttributes(context); UserModel user = session.users().getUserById(realm, context.getResourceId()); if (user != null && user.getEmail() != null) { @@ -76,110 +70,109 @@ public class NotifyUserStepProvider implements WorkflowStepProvider { } } - private String getSubjectKey() { - String nextStepType = getNextStepType(); - String customSubjectKey = stepModel.getConfig().getFirst("custom_subject_key"); + private String getSubjectKey(WorkflowExecutionContext context) { + String customSubjectKey = stepModel.getConfig().getFirst("subject"); if (customSubjectKey != null && !customSubjectKey.trim().isEmpty()) { return customSubjectKey; } - - // Return default subject key based on next step type - return getDefaultSubjectKey(nextStepType); + + WorkflowStep nextStep = context.getNextStep(); + + if (nextStep == null || nextStep.getNotificationSubject() == null) { + return "accountNotificationSubject"; + } + + return nextStep.getNotificationSubject(); } private String getBodyTemplate() { return "workflow-notification.ftl"; } - private Map getBodyAttributes() { + private Map getBodyAttributes(WorkflowExecutionContext context) { RealmModel realm = session.getContext().getRealm(); Map attributes = new HashMap<>(); - - String nextStepType = getNextStepType(); - + WorkflowStep nextStep = context.getNextStep(); + // Custom message override or default based on step type - String customMessage = stepModel.getConfig().getFirst("custom_message"); + String customMessage = stepModel.getConfig().getFirst("message"); if (customMessage != null && !customMessage.trim().isEmpty()) { attributes.put("messageKey", "customMessage"); - attributes.put("customMessage", customMessage); + attributes.put("customMessage", replaceProperties(customMessage, new NotificationPropertyResolver(session, context))); + } else if (nextStep != null && nextStep.getNotificationMessage() != null) { + attributes.put("messageKey", nextStep.getNotificationMessage()); } else { - attributes.put("messageKey", getDefaultMessageKey(nextStepType)); + attributes.put("messageKey", "accountNotificationBody"); } // Calculate days remaining until next step - int daysRemaining = calculateDaysUntilNextStep(); + int daysRemaining = calculateDaysUntilNextStep(context); // Message parameters for internationalization attributes.put("daysRemaining", daysRemaining); attributes.put("reason", stepModel.getConfig().getFirstOrDefault("reason", "inactivity")); attributes.put("realmName", realm.getDisplayName() != null ? realm.getDisplayName() : realm.getName()); - attributes.put("nextStepType", nextStepType); - attributes.put("subjectKey", getSubjectKey()); + + if (nextStep != null) { + attributes.put("nextStepType", nextStep.getProviderId()); + } + + attributes.put("subjectKey", getSubjectKey(context)); return attributes; } - private String getNextStepType() { - Map nextStepMap = getNextNonNotificationStep(); - return nextStepMap.isEmpty() ? "unknown-step" : nextStepMap.keySet().iterator().next().getProviderId(); - } + private int calculateDaysUntilNextStep(WorkflowExecutionContext context) { + WorkflowStep nextStep = context.getNextStep(); - private int calculateDaysUntilNextStep() { - Map nextStepMap = getNextNonNotificationStep(); - if (nextStepMap.isEmpty()) { + if (nextStep == null || nextStep.getAfter() == null) { return 0; } - Duration timeToNextStep = nextStepMap.values().iterator().next(); - return Math.toIntExact(timeToNextStep.toDays()); + + return Math.toIntExact(DurationConverter.parseDuration(nextStep.getAfter()).toDays()); } - private Map getNextNonNotificationStep() { - Duration timeToNextNonNotificationStep = Duration.ZERO; + private class NotificationPropertyResolver implements PropertyResolver { - RealmModel realm = session.getContext().getRealm(); - ComponentModel workflowModel = realm.getComponent(stepModel.getParentId()); - - List steps = realm.getComponentsStream(workflowModel.getId(), WorkflowStepProvider.class.getName()) - .sorted((a, b) -> { - int priorityA = Integer.parseInt(a.get("priority", "0")); - int priorityB = Integer.parseInt(b.get("priority", "0")); - return Integer.compare(priorityA, priorityB); - }) - .toList(); - - // Find current step and return next non-notification step - boolean foundCurrent = false; - for (ComponentModel step : steps) { - if (foundCurrent) { - Duration duration = DurationConverter.parseDuration(step.get(CONFIG_AFTER, "0")); - timeToNextNonNotificationStep = timeToNextNonNotificationStep.plus(duration != null ? duration : Duration.ZERO); - if (!step.getProviderId().equals("notify-user")) { - // we found the next non-notification action, accumulate its time and break - return Map.of(step, timeToNextNonNotificationStep); - } - } - if (step.getId().equals(stepModel.getId())) { - foundCurrent = true; - } + private final KeycloakSession session; + private final WorkflowExecutionContext context; + + public NotificationPropertyResolver(KeycloakSession session, WorkflowExecutionContext context) { + this.session = session; + this.context = context; } - - return Map.of(); - } - - private String getDefaultSubjectKey(String stepType) { - return switch (stepType) { - case DisableUserStepProviderFactory.ID -> ACCOUNT_DISABLE_NOTIFICATION_SUBJECT; - case DeleteUserStepProviderFactory.ID -> ACCOUNT_DELETE_NOTIFICATION_SUBJECT; - default -> "accountNotificationSubject"; - }; - } - private String getDefaultMessageKey(String stepType) { - return switch (stepType) { - case DisableUserStepProviderFactory.ID -> ACCOUNT_DISABLE_NOTIFICATION_BODY; - case DeleteUserStepProviderFactory.ID -> ACCOUNT_DELETE_NOTIFICATION_BODY; - default -> "accountNotificationBody"; - }; + @Override + public String resolve(String property) { + if (property.startsWith("user.")) { + String userId = context.getResourceId(); + RealmModel realm = session.getContext().getRealm(); + UserModel user = session.users().getUserById(realm, userId); + + if (user == null) { + return null; + } + + String attributeKey = property.substring("user.".length()); + + return user.getFirstAttribute(attributeKey); + } else if (property.startsWith("realm.")) { + RealmModel realm = session.getContext().getRealm(); + String attributeKey = property.substring("realm.".length()); + + if (attributeKey.equals("name")) { + return realm.getName(); + } else if (attributeKey.equals("displayName")) { + return realm.getDisplayName(); + } + + return null; + } else if ("workflow.daysUntilNextStep".equals(property)) { + return String.valueOf(calculateDaysUntilNextStep(context)); + } + + return null; + } } } diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/NotificationStepTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/NotificationStepTest.java new file mode 100644 index 00000000000..83ab4661ec2 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/NotificationStepTest.java @@ -0,0 +1,105 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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.keycloak.tests.admin.model.workflow; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; + +import jakarta.mail.internet.MimeMessage; + +import org.keycloak.admin.client.Keycloak; +import org.keycloak.models.workflow.DisableUserStepProviderFactory; +import org.keycloak.models.workflow.NotifyUserStepProviderFactory; +import org.keycloak.representations.workflows.WorkflowRepresentation; +import org.keycloak.representations.workflows.WorkflowStepRepresentation; +import org.keycloak.testframework.annotations.InjectAdminClient; +import org.keycloak.testframework.annotations.InjectKeycloakUrls; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.mail.MailServer; +import org.keycloak.testframework.mail.annotations.InjectMailServer; +import org.keycloak.testframework.realm.UserConfigBuilder; +import org.keycloak.testframework.server.KeycloakUrls; +import org.keycloak.tests.utils.MailUtils; + +import org.junit.jupiter.api.Test; + +import static org.keycloak.models.workflow.ResourceOperationType.USER_ADDED; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@KeycloakIntegrationTest(config = WorkflowsBlockingServerConfig.class) +public class NotificationStepTest extends AbstractWorkflowTest { + + @InjectMailServer + private MailServer mailServer; + + @InjectKeycloakUrls + KeycloakUrls keycloakUrls; + + @InjectAdminClient(ref = "managed", realmRef = "managedRealm") + Keycloak adminClient; + + @Test + public void testNotifyUserStepWithCustomMessageOverride() throws IOException { + // Create workflow: disable at 7 days, notify 2 days before (at day 5) with custom message + managedRealm.admin().workflows().create(WorkflowRepresentation.withName("myworkflow") + .onEvent(USER_ADDED.name()) + .withSteps( + WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID) + .withConfig("message", "

Dear ${user.firstName} ${user.lastName},

\n" + + "\n" + + "

Welcome to ${realm.name}!

\n" + + "

The next step is scheduled to ${workflow.daysUntilNextStep} days.

\n" + + "\n" + + "

\n" + + " Best regards,
\n" + + " ${realm.name} team\n" + + "

") + .withConfig("subject", "customComplianceSubject") + .build(), + WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID) + .after(Duration.ofDays(7)) + .build() + ).build()).close(); + + try { + managedRealm.admin().users().create( + UserConfigBuilder.create() + .username("testuser3") + .email("test3@example.com") + .name("Bob", "Doe") + .build() + ).close(); + + MimeMessage message = mailServer.getLastReceivedMessage(); + assertNotNull(message); + + MailUtils.EmailBody body = MailUtils.getBody(message); + + for (String content : List.of(body.getText(), body.getHtml())) { + assertTrue(content.contains("Dear Bob Doe,")); + assertTrue(content.contains("Welcome to " + managedRealm.getName() + "!")); + assertTrue(content.contains("The next step is scheduled to 7 days.")); + } + } finally { + mailServer.runCleanup(); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserSessionRefreshTimeWorkflowTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserSessionRefreshTimeWorkflowTest.java index 78df66b923c..535909f3431 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserSessionRefreshTimeWorkflowTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserSessionRefreshTimeWorkflowTest.java @@ -142,8 +142,8 @@ public class UserSessionRefreshTimeWorkflowTest extends AbstractWorkflowTest { .withSteps( WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID) .after(Duration.ofDays(5)) - .withConfig("custom_subject_key", "notifier1_subject") - .withConfig("custom_message", "notifier1_message") + .withConfig("subject", "notifier1_subject") + .withConfig("message", "notifier1_message") .build()) .build()).close(); managedRealm.admin().workflows().create(WorkflowRepresentation.withName("myworkflow_2") @@ -151,8 +151,8 @@ public class UserSessionRefreshTimeWorkflowTest extends AbstractWorkflowTest { .withSteps( WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID) .after(Duration.ofDays(10)) - .withConfig("custom_subject_key", "notifier2_subject") - .withConfig("custom_message", "notifier2_message") + .withConfig("subject", "notifier2_subject") + .withConfig("message", "notifier2_message") .build()) .build()).close(); diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowManagementTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowManagementTest.java index 65e18cedbe1..22e9b996d3d 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowManagementTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowManagementTest.java @@ -917,8 +917,8 @@ public class WorkflowManagementTest extends AbstractWorkflowTest { WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID) .after(Duration.ofDays(5)) .withConfig("reason", "compliance requirement") - .withConfig("custom_message", "Your account requires immediate attention due to new compliance policies.") - .withConfig("custom_subject_key", "customComplianceSubject") + .withConfig("message", "${user.firstName}, your account requires immediate attention due to new compliance policies.") + .withConfig("subject", "customComplianceSubject") .build(), WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID) .after(Duration.ofDays(7)) diff --git a/themes/src/main/resources/theme/base/email/html/workflow-notification.ftl b/themes/src/main/resources/theme/base/email/html/workflow-notification.ftl index 04e422274fd..493a6b6c1f9 100644 --- a/themes/src/main/resources/theme/base/email/html/workflow-notification.ftl +++ b/themes/src/main/resources/theme/base/email/html/workflow-notification.ftl @@ -2,22 +2,23 @@ <@layout.emailLayout>

${kcSanitize(msg(subjectKey, daysRemaining, reason))?no_esc}

-

Dear ${user.firstName!user.username},

- <#if messageKey == "customMessage"> -

${kcSanitize(customMessage)?no_esc}

+

${kcSanitize(customMessage)?no_esc}

<#else> -

${kcSanitize(msg(messageKey, daysRemaining, reason))?no_esc}

+

Dear ${user.firstName!user.username},

+ +

${kcSanitize(msg(messageKey, daysRemaining, reason))?no_esc}

+ + <#if daysRemaining gt 0> +

Time remaining: ${daysRemaining} day<#if daysRemaining != 1>s

+ + +

If you have questions, please contact your ${realmName} administrator.

+ +

+ Best regards,
+ ${realmName} Administration +

-<#if daysRemaining gt 0> -

Time remaining: ${daysRemaining} day<#if daysRemaining != 1>s

- - -

If you have questions, please contact your ${realmName} administrator.

- -

-Best regards,
-${realmName} Administration -

\ No newline at end of file diff --git a/themes/src/main/resources/theme/base/email/text/workflow-notification.ftl b/themes/src/main/resources/theme/base/email/text/workflow-notification.ftl index a6267fe66ba..c4511f234de 100644 --- a/themes/src/main/resources/theme/base/email/text/workflow-notification.ftl +++ b/themes/src/main/resources/theme/base/email/text/workflow-notification.ftl @@ -1,18 +1,18 @@ ${kcSanitize(msg(subjectKey, daysRemaining, reason))?no_esc} -Dear ${user.firstName!user.username}, - <#if messageKey == "customMessage"> ${kcSanitize(customMessage)?no_esc} <#else> +Dear ${user.firstName!user.username}, + ${kcSanitize(msg(messageKey, daysRemaining, reason))?no_esc} - <#if daysRemaining gt 0> -Time remaining: ${daysRemaining} day<#if daysRemaining != 1>s + Time remaining: ${daysRemaining} day<#if daysRemaining != 1>s If you have questions, please contact your ${realmName} administrator. Best regards, -${realmName} Administration \ No newline at end of file +${realmName} Administration + \ No newline at end of file