Skip to content

Commit fc3914c

Browse files
martin-kanispedroigor
authored andcommitted
[RLM] Provide a action to notify users by email based on a configurable time
Closes keycloak#41788 Signed-off-by: Martin Kanis <[email protected]>
1 parent 35e6d75 commit fc3914c

File tree

11 files changed

+597
-58
lines changed

11 files changed

+597
-58
lines changed

core/src/main/java/org/keycloak/representations/resources/policies/ResourcePolicyActionRepresentation.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,15 @@ public Builder after(Duration duration) {
7777
return this;
7878
}
7979

80+
public Builder before(ResourcePolicyActionRepresentation targetAction, Duration timeBeforeTarget) {
81+
// Calculate absolute time: targetAction.after - timeBeforeTarget
82+
String targetAfter = targetAction.getConfig().get(AFTER_KEY).get(0);
83+
long targetTime = Long.parseLong(targetAfter);
84+
long thisTime = targetTime - timeBeforeTarget.toMillis();
85+
action.setAfter(thisTime);
86+
return this;
87+
}
88+
8089
public Builder withConfig(String key, String value) {
8190
action.setConfig(key, value);
8291
return this;

services/src/main/java/org/keycloak/models/policy/NotifyUserActionProvider.java

Lines changed: 137 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,27 @@
1717

1818
package org.keycloak.models.policy;
1919

20+
import java.time.Duration;
21+
import java.util.HashMap;
2022
import java.util.List;
23+
import java.util.Map;
24+
2125
import org.jboss.logging.Logger;
2226

2327
import org.keycloak.component.ComponentModel;
28+
import org.keycloak.email.EmailException;
29+
import org.keycloak.email.EmailTemplateProvider;
2430
import org.keycloak.models.KeycloakSession;
2531
import org.keycloak.models.RealmModel;
2632
import org.keycloak.models.UserModel;
2733

2834
public class NotifyUserActionProvider implements ResourceActionProvider {
2935

36+
private static final String ACCOUNT_DISABLE_NOTIFICATION_SUBJECT = "accountDisableNotificationSubject";
37+
private static final String ACCOUNT_DELETE_NOTIFICATION_SUBJECT = "accountDeleteNotificationSubject";
38+
private static final String ACCOUNT_DISABLE_NOTIFICATION_BODY = "accountDisableNotificationBody";
39+
private static final String ACCOUNT_DELETE_NOTIFICATION_BODY = "accountDeleteNotificationBody";
40+
3041
private final KeycloakSession session;
3142
private final ComponentModel actionModel;
3243
private final Logger log = Logger.getLogger(NotifyUserActionProvider.class);
@@ -43,23 +54,142 @@ public void close() {
4354
@Override
4455
public void run(List<String> userIds) {
4556
RealmModel realm = session.getContext().getRealm();
57+
EmailTemplateProvider emailProvider = session.getProvider(EmailTemplateProvider.class).setRealm(realm);
58+
59+
String subjectKey = getSubjectKey();
60+
String bodyTemplate = getBodyTemplate();
61+
Map<String, Object> bodyAttributes = getBodyAttributes();
4662

4763
for (String id : userIds) {
4864
UserModel user = session.users().getUserById(realm, id);
4965

50-
if (user != null) {
51-
log.debugv("Disabling user {0} ({1})", user.getUsername(), user.getId());
52-
user.setSingleAttribute(getMessageKey(), getMessage());
66+
if (user != null && user.getEmail() != null) {
67+
try {
68+
emailProvider.setUser(user).send(subjectKey, bodyTemplate, bodyAttributes);
69+
log.debugv("Notification email sent to user {0} ({1})", user.getUsername(), user.getEmail());
70+
} catch (EmailException e) {
71+
log.errorv(e, "Failed to send notification email to user {0} ({1})", user.getUsername(), user.getEmail());
72+
}
73+
} else if (user != null && user.getEmail() == null) {
74+
log.warnv("User {0} has no email address, skipping notification", user.getUsername());
5375
}
5476
}
5577
}
5678

57-
private String getMessageKey() {
58-
return actionModel.getConfig().getFirstOrDefault("message_key", "message");
79+
private String getSubjectKey() {
80+
String nextActionType = getNextActionType();
81+
String customSubjectKey = actionModel.getConfig().getFirst("custom_subject_key");
82+
83+
if (customSubjectKey != null && !customSubjectKey.trim().isEmpty()) {
84+
return customSubjectKey;
85+
}
86+
87+
// Return default subject key based on next action type
88+
String defaultSubjectKey = getDefaultSubjectKey(nextActionType);
89+
return defaultSubjectKey;
90+
}
91+
92+
private String getBodyTemplate() {
93+
return "resource-policy-notification.ftl";
94+
}
95+
96+
private Map<String, Object> getBodyAttributes() {
97+
RealmModel realm = session.getContext().getRealm();
98+
Map<String, Object> attributes = new HashMap<>();
99+
100+
String nextActionType = getNextActionType();
101+
102+
// Custom message override or default based on action type
103+
String customMessage = actionModel.getConfig().getFirst("custom_message");
104+
if (customMessage != null && !customMessage.trim().isEmpty()) {
105+
attributes.put("messageKey", "customMessage");
106+
attributes.put("customMessage", customMessage);
107+
} else {
108+
attributes.put("messageKey", getDefaultMessageKey(nextActionType));
109+
}
110+
111+
// Calculate days remaining until next action
112+
int daysRemaining = calculateDaysUntilNextAction();
113+
114+
// Message parameters for internationalization
115+
attributes.put("daysRemaining", daysRemaining);
116+
attributes.put("reason", actionModel.getConfig().getFirstOrDefault("reason", "inactivity"));
117+
attributes.put("realmName", realm.getDisplayName() != null ? realm.getDisplayName() : realm.getName());
118+
attributes.put("nextActionType", nextActionType);
119+
attributes.put("subjectKey", getSubjectKey());
120+
121+
return attributes;
122+
}
123+
124+
private String getNextActionType() {
125+
ComponentModel nextAction = getNextNonNotificationAction();
126+
return nextAction != null ? nextAction.getProviderId() : "unknown-action";
127+
}
128+
129+
private int calculateDaysUntilNextAction() {
130+
ComponentModel nextAction = getNextNonNotificationAction();
131+
if (nextAction == null) {
132+
return 0;
133+
}
134+
135+
String currentAfter = actionModel.get("after");
136+
String nextAfter = nextAction.get("after");
137+
138+
if (currentAfter == null || nextAfter == null) {
139+
return 0;
140+
}
141+
142+
try {
143+
long currentMillis = Long.parseLong(currentAfter);
144+
long nextMillis = Long.parseLong(nextAfter);
145+
Duration difference = Duration.ofMillis(nextMillis - currentMillis);
146+
return Math.toIntExact(difference.toDays());
147+
} catch (NumberFormatException e) {
148+
log.warnv("Invalid days format: current={0}, next={1}", currentAfter, nextAfter);
149+
return 0;
150+
}
151+
}
152+
153+
private ComponentModel getNextNonNotificationAction() {
154+
RealmModel realm = session.getContext().getRealm();
155+
ComponentModel policyModel = realm.getComponent(actionModel.getParentId());
156+
157+
List<ComponentModel> actions = realm.getComponentsStream(policyModel.getId(), ResourceActionProvider.class.getName())
158+
.sorted((a, b) -> {
159+
int priorityA = Integer.parseInt(a.get("priority", "0"));
160+
int priorityB = Integer.parseInt(b.get("priority", "0"));
161+
return Integer.compare(priorityA, priorityB);
162+
})
163+
.toList();
164+
165+
// Find current action and return next non-notification action
166+
boolean foundCurrent = false;
167+
for (ComponentModel action : actions) {
168+
if (foundCurrent && !action.getProviderId().equals("notify-user-action-provider")) {
169+
return action;
170+
}
171+
if (action.getId().equals(actionModel.getId())) {
172+
foundCurrent = true;
173+
}
174+
}
175+
176+
return null;
177+
}
178+
179+
private String getDefaultSubjectKey(String actionType) {
180+
return switch (actionType) {
181+
case "disable-user-action-provider" -> ACCOUNT_DISABLE_NOTIFICATION_SUBJECT;
182+
case "delete-user-action-provider" -> ACCOUNT_DELETE_NOTIFICATION_SUBJECT;
183+
default -> "accountNotificationSubject";
184+
};
59185
}
60186

61-
private String getMessage() {
62-
return actionModel.getConfig().getFirstOrDefault(getMessageKey(), "sent");
187+
private String getDefaultMessageKey(String actionType) {
188+
return switch (actionType) {
189+
case "disable-user-action-provider" -> ACCOUNT_DISABLE_NOTIFICATION_BODY;
190+
case "delete-user-action-provider" -> ACCOUNT_DELETE_NOTIFICATION_BODY;
191+
default -> "accountNotificationBody";
192+
};
63193
}
64194

65195
@Override

services/src/main/java/org/keycloak/models/policy/NotifyUserActionProviderFactory.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
package org.keycloak.models.policy;
1919

20+
import java.util.Arrays;
2021
import java.util.List;
2122

2223
import org.keycloak.Config;
@@ -61,11 +62,23 @@ public ResourceType getType() {
6162

6263
@Override
6364
public String getHelpText() {
64-
return "";
65+
return "Sends email notifications to users based on configurable templates";
6566
}
6667

6768
@Override
6869
public List<ProviderConfigProperty> getConfigProperties() {
69-
return List.of();
70+
return Arrays.asList(
71+
new ProviderConfigProperty("reason", "Reason",
72+
"Reason for the action (inactivity, policy violation, compliance requirement)",
73+
ProviderConfigProperty.STRING_TYPE, ""),
74+
75+
new ProviderConfigProperty("custom_subject_key", "Custom Subject Message Key",
76+
"Override default subject with custom message property key (optional)",
77+
ProviderConfigProperty.STRING_TYPE, ""),
78+
79+
new ProviderConfigProperty("custom_message", "Custom Message",
80+
"Override default message with custom text (optional)",
81+
ProviderConfigProperty.TEXT_TYPE, "")
82+
);
7083
}
7184
}

services/src/main/java/org/keycloak/models/policy/UserActionBuilder.java

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

2020
import java.time.Duration;
2121

22-
import org.keycloak.common.util.KeycloakUriBuilder;
23-
2422
public class UserActionBuilder {
2523

2624
private final ResourceAction action;

tests/base/src/test/java/org/keycloak/tests/admin/model/policy/GroupMembershipJoinPolicyTest.java

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
import static org.hamcrest.MatcherAssert.assertThat;
44
import static org.hamcrest.Matchers.is;
55
import static org.junit.jupiter.api.Assertions.assertNotNull;
6-
import static org.junit.jupiter.api.Assertions.assertNull;
6+
import static org.keycloak.tests.admin.model.policy.ResourcePolicyManagementTest.findEmailByRecipient;
77

88
import java.time.Duration;
99
import java.util.List;
1010

11+
import jakarta.mail.internet.MimeMessage;
1112
import jakarta.ws.rs.core.Response;
1213
import jakarta.ws.rs.core.Response.Status;
1314
import org.junit.jupiter.api.Test;
@@ -27,6 +28,8 @@
2728
import org.keycloak.testframework.annotations.InjectRealm;
2829
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
2930
import org.keycloak.testframework.injection.LifeCycle;
31+
import org.keycloak.testframework.mail.MailServer;
32+
import org.keycloak.testframework.mail.annotations.InjectMailServer;
3033
import org.keycloak.testframework.realm.GroupConfigBuilder;
3134
import org.keycloak.testframework.realm.ManagedRealm;
3235
import org.keycloak.testframework.realm.UserConfigBuilder;
@@ -45,6 +48,9 @@ public class GroupMembershipJoinPolicyTest {
4548
@InjectRealm(lifecycle = LifeCycle.METHOD)
4649
ManagedRealm managedRealm;
4750

51+
@InjectMailServer
52+
private MailServer mailServer;
53+
4854
@Test
4955
public void testEventsOnGroupMembershipJoin() {
5056
String groupId;
@@ -77,7 +83,7 @@ public void testEventsOnGroupMembershipJoin() {
7783
String userId;
7884

7985
try (Response response = managedRealm.admin().users().create(UserConfigBuilder.create()
80-
.username("generic-user").build())) {
86+
.username("generic-user").email("[email protected]").build())) {
8187
userId = ApiUtil.getCreatedId(response);
8288
}
8389

@@ -88,18 +94,21 @@ public void testEventsOnGroupMembershipJoin() {
8894
ResourcePolicyManager manager = new ResourcePolicyManager(session);
8995

9096
UserModel user = session.users().getUserById(realm, userId);
91-
assertNull(user.getAttributes().get("message"));
9297

9398
try {
9499
// set offset to 7 days - notify action should run now
95100
Time.setOffset(Math.toIntExact(Duration.ofDays(6).toSeconds()));
96101
manager.runScheduledActions();
97-
user = session.users().getUserById(realm, userId);
98-
assertNotNull(user.getAttributes().get("message"));
99102
} finally {
100103
Time.setOffset(0);
101104
}
102105
}));
106+
107+
// Verify that the notify action was executed by checking email was sent
108+
MimeMessage testUserMessage = findEmailByRecipient(mailServer, "[email protected]");
109+
assertNotNull(testUserMessage, "The first action (notify) should have sent an email.");
110+
111+
mailServer.runCleanup();
103112
}
104113

105114
private static RealmModel configureSessionContext(KeycloakSession session) {

0 commit comments

Comments
 (0)