1717
1818package org .keycloak .models .policy ;
1919
20+ import java .time .Duration ;
21+ import java .util .HashMap ;
2022import java .util .List ;
23+ import java .util .Map ;
24+
2125import org .jboss .logging .Logger ;
2226
2327import org .keycloak .component .ComponentModel ;
28+ import org .keycloak .email .EmailException ;
29+ import org .keycloak .email .EmailTemplateProvider ;
2430import org .keycloak .models .KeycloakSession ;
2531import org .keycloak .models .RealmModel ;
2632import org .keycloak .models .UserModel ;
2733
2834public 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
0 commit comments