001/*
002 *  Copyright 2010 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.cms.workflow;
017
018import java.io.IOException;
019import java.time.ZonedDateTime;
020import java.util.ArrayList;
021import java.util.HashSet;
022import java.util.Iterator;
023import java.util.List;
024import java.util.Map;
025import java.util.Objects;
026import java.util.Set;
027import java.util.stream.Collectors;
028
029import org.apache.avalon.framework.activity.Initializable;
030import org.apache.avalon.framework.context.Context;
031import org.apache.avalon.framework.context.ContextException;
032import org.apache.avalon.framework.context.Contextualizable;
033import org.apache.cocoon.components.ContextHelper;
034import org.apache.cocoon.environment.Request;
035import org.apache.commons.lang3.StringUtils;
036import org.apache.commons.lang3.tuple.Pair;
037import org.apache.excalibur.source.SourceResolver;
038
039import org.ametys.cms.repository.WorkflowAwareContent;
040import org.ametys.core.right.Right;
041import org.ametys.core.right.RightManager;
042import org.ametys.core.right.RightsExtensionPoint;
043import org.ametys.core.ui.mail.StandardMailBodyHelper;
044import org.ametys.core.ui.mail.StandardMailBodyHelper.MailBodyBuilder;
045import org.ametys.core.ui.mail.StandardMailBodyHelper.MailBodyBuilder.UserInput;
046import org.ametys.core.user.User;
047import org.ametys.core.user.UserIdentity;
048import org.ametys.core.user.UserManager;
049import org.ametys.core.util.I18nUtils;
050import org.ametys.core.util.language.UserLanguagesManager;
051import org.ametys.core.util.mail.SendMailHelper;
052import org.ametys.plugins.workflow.EnhancedFunction;
053import org.ametys.plugins.workflow.component.WorkflowArgument;
054import org.ametys.plugins.workflow.support.WorkflowElementDefinitionHelper;
055import org.ametys.plugins.workflow.support.WorkflowProvider;
056import org.ametys.runtime.config.Config;
057import org.ametys.runtime.i18n.I18nizableText;
058import org.ametys.runtime.i18n.I18nizableTextParameter;
059import org.ametys.runtime.model.StaticEnumerator;
060import org.ametys.runtime.plugin.component.PluginAware;
061
062import com.opensymphony.module.propertyset.PropertySet;
063import com.opensymphony.workflow.WorkflowException;
064
065import jakarta.mail.MessagingException;
066
067/**
068 * OS workflow function to send mail after an action is triggered.
069 */
070public class SendMailFunction extends AbstractContentWorkflowComponent implements EnhancedFunction, Initializable, PluginAware, Contextualizable
071{
072    /**
073     * Provide "false" to prevent the function sending the mail.
074     * Useful when making large automatic workflow operations (for instance, when bulk importing and proposing in one action).
075     */
076    public static final String SEND_MAIL = "send-mail";
077    
078    /** The rights key. */
079    protected static final String RIGHTS_KEY = "rights";
080    /** The mail subject key. */
081    protected static final String SUBJECT_KEY = "subjectKey";
082    /** The mail body key. */
083    protected static final String BODY_KEY = "bodyKey";
084    
085    /** The rights manager. */
086    protected RightManager _rightManager;
087    
088    /** The users manager. */
089    protected UserManager _userManager;
090    
091    /** The source resolver. */
092    protected SourceResolver _sourceResolver;
093    
094    /** The workflow. */
095    protected WorkflowProvider _workflowProvider;
096    
097    /** The Avalon context. */
098    protected Context _context;
099    
100    /** The plugin name. */
101    protected String _pluginName;
102    
103    /** I18nUtils */
104    protected I18nUtils _i18nUtils;
105    
106    /** The rights extension point */
107    protected RightsExtensionPoint _rightsExtensionPoint;
108    
109    /** The user languages manager */
110    protected UserLanguagesManager _userLanguagesManager;
111    
112    @Override
113    public void initialize() throws Exception
114    {
115        _rightManager = (RightManager) _manager.lookup(RightManager.ROLE);
116        _userManager = (UserManager) _manager.lookup(UserManager.ROLE);
117        _sourceResolver = (SourceResolver) _manager.lookup(SourceResolver.ROLE);
118        _workflowProvider = (WorkflowProvider) _manager.lookup(WorkflowProvider.ROLE);
119        _i18nUtils = (I18nUtils) _manager.lookup(I18nUtils.ROLE);
120        _rightsExtensionPoint = (RightsExtensionPoint) _manager.lookup(RightsExtensionPoint.ROLE);
121        _userLanguagesManager = (UserLanguagesManager) _manager.lookup(UserLanguagesManager.ROLE);
122    }
123    
124    public void contextualize(Context context) throws ContextException
125    {
126        _context = context;
127    }
128    
129    @Override
130    public void setPluginInfo(String pluginName, String featureName, String id)
131    {
132        _pluginName = pluginName;
133    }
134    
135    @Override
136    public void execute(Map transientVars, Map args, PropertySet ps) throws WorkflowException
137    {
138        String rightsParam = StringUtils.defaultString((String) args.get(RIGHTS_KEY));
139        String subjectI18nKey = StringUtils.defaultString((String) args.get(SUBJECT_KEY));
140        String bodyI18nKey = StringUtils.defaultString((String) args.get(BODY_KEY));
141        
142        Set<String> rights = _getRights(rightsParam);
143        
144        // If "send-mail" is set to true or is not present in the vars, send the mail.
145        boolean dontSendMail = "false".equals(transientVars.get(SEND_MAIL));
146        
147        if (dontSendMail)
148        {
149            return;
150        }
151        
152        try
153        {
154            WorkflowAwareContent content = getContent(transientVars);
155            
156            Map<String, Set<String>> recipientsByLanguage = getRecipientsByLanguage(transientVars, content, rights);
157            
158            if (recipientsByLanguage.size() > 0)
159            {
160                User caller = getCaller(transientVars, content);
161                
162                String sender = getSender(transientVars, content);
163                MailBodyBuilder mailBodyBuilder = getMailBody(subjectI18nKey, bodyI18nKey, caller, content, transientVars);
164                
165                I18nizableText mailSubjectKey = getMailSubject(subjectI18nKey, caller, content, transientVars, false);
166                _sendMails(mailSubjectKey, mailBodyBuilder, recipientsByLanguage, sender);
167            }
168        }
169        catch (Exception e)
170        {
171            _logger.error("An error occurred: unable to send mail to notify workflow change.", e);
172        }
173    }
174
175    private Set<String> _getRights(String rightsParam)
176    {
177        Set<String> rights = new HashSet<>();
178        for (String right : rightsParam.split(","))
179        {
180            if (StringUtils.isNotBlank(right))
181            {
182                rights.add(right.trim());
183            }
184        }
185        return rights;
186    }
187    
188    /**
189     * Get the subject of mail
190     * @param subjectI18nKey  the i18n key to use for subject
191     * @param user the caller
192     * @param content the content
193     * @param transientVars the transient variables
194     * @param escapeHTML true to escape HTML, false otherwise. Usefull when title is used in html body
195     * @return the subject
196     */
197    protected I18nizableText getMailSubject (String subjectI18nKey, User user, WorkflowAwareContent content, Map transientVars, boolean escapeHTML)
198    {
199        return new I18nizableText(null, subjectI18nKey, getSubjectI18nParams(user, content, escapeHTML));
200    }
201    
202    /**
203     * Get the text body of mail
204     * @param subjectI18nKey the i18n key to use for body's title
205     * @param bodyI18nKey the i18n key to use for body
206     * @param user the caller
207     * @param content the content
208     * @param transientVars the transient variables
209     * @return the text body
210     * @throws IOException if an error occurred while building HTML workflow email
211     */
212    protected MailBodyBuilder getMailBody (String subjectI18nKey, String bodyI18nKey, User user, WorkflowAwareContent content, Map transientVars) throws IOException
213    {
214        MailBodyBuilder bodyBuilder = StandardMailBodyHelper.newHTMLBody()
215            .withMessage(new I18nizableText(null, bodyI18nKey, getBodyI18nParams(user, content)))
216            .withLink(_getContentUri(content), new I18nizableText("plugin.cms", "WORKFLOW_MAIL_BODY_GO_TO_CONTENT"));
217        
218        // Get the workflow comment
219        String comment = (String) transientVars.get("comment");
220        if (StringUtils.isNotEmpty(comment))
221        {
222            bodyBuilder.withUserInputs(List.of(new UserInput(user, ZonedDateTime.now(), comment)), new I18nizableText("plugin.cms", "WORKFLOW_MAIL_BODY_USER_COMMENT"));
223        }
224        
225        return bodyBuilder;
226    }
227    
228    /**
229     * Send the notification emails.
230     * @param subjectI18nKey the e-mail subject.
231     * @param bodyBuilder the e-mail body builder.
232     * @param recipientsByLanguage the recipients emails address by language.
233     * @param from the address sending the e-mail.
234     * @throws IOException If an error occurs while building the body
235     */
236    protected void _sendMails(I18nizableText subjectI18nKey, MailBodyBuilder bodyBuilder, Map<String, Set<String>> recipientsByLanguage, String from) throws IOException
237    {
238        for (String language : recipientsByLanguage.keySet())
239        {
240            String subject = _i18nUtils.translate(subjectI18nKey, language);
241            
242            String body = bodyBuilder.withTitle(subject)
243                .withLanguage(language)
244                .build();
245            
246            for (String recipient : recipientsByLanguage.get(language))
247            {
248                try
249                {
250                    SendMailHelper.newMail()
251                                  .withSubject(subject)
252                                  .withHTMLBody(body)
253                                  .withSender(from)
254                                  .withRecipient(recipient)
255                                  .withAsync(true)
256                                  .sendMail();
257                }
258                catch (MessagingException | IOException e)
259                {
260                    _logger.warn("Could not send a workflow notification mail to " + recipient, e);
261                }
262            }
263        }
264    }
265    
266    /**
267     * Get the i18n parameters of mail subject
268     * @param user the caller
269     * @param content the content
270     * @param escapeHTML true to escape HTML, false otherwise. Usefull when title is used in html body
271     * @return the i18n parameters
272     */
273    protected List<String> getSubjectI18nParams (User user, WorkflowAwareContent content, boolean escapeHTML)
274    {
275        List<String> params = new ArrayList<>();
276        params.add(escapeHTML ? org.ametys.core.util.StringUtils.escapeHTML(_contentHelper.getTitle(content)) : _contentHelper.getTitle(content));
277        return params;
278    }
279    
280    /**
281     * Get the i18n parameters of mail body text
282     * @param user the caller
283     * @param content the content
284     * @return the i18n parameters
285     */
286    protected List<String> getBodyI18nParams (User user, WorkflowAwareContent content)
287    {
288        List<String> params = new ArrayList<>();
289        
290        params.add(org.ametys.core.util.StringUtils.escapeHTML(user.getFullName())); // {0}
291        params.add(org.ametys.core.util.StringUtils.escapeHTML(content.getTitle())); // {1}
292        params.add(_getContentUri(content)); // {2}
293        
294        return params;
295    }
296    
297    /**
298     * Get the content uri
299     * @param content the content
300     * @return the content uri
301     */
302    protected String _getContentUri(WorkflowAwareContent content)
303    {
304        return _contentHelper.getContentBOUrl(content, Map.of());
305    }
306    
307    /**
308     * Retrieve the request from which this component is called.
309     * @return the request or <code>null</code>.
310     */
311    public Request _getRequest()
312    {
313        try
314        {
315            return (Request) _context.get(ContextHelper.CONTEXT_REQUEST_OBJECT);
316        }
317        catch (ContextException ce)
318        {
319            _logger.info("Unable to get the request", ce);
320            return null;
321        }
322    }
323    
324    /**
325     * Get the caller of the workflow action
326     * @param transientVars the transient variables
327     * @param content content the content
328     * @return caller the caller if the workflow function
329     * @throws WorkflowException if failed to get caller
330     */
331    public User getCaller(Map transientVars, WorkflowAwareContent content) throws WorkflowException
332    {
333        UserIdentity userIdentity = getUser(transientVars);
334        return userIdentity != null ? _userManager.getUser(userIdentity) : null;
335    }
336    
337    /**
338     * Get the sender for mail
339     * @param transientVars the transient variables
340     * @param content the content
341     * @return the sender email address
342     * @throws WorkflowException if failed to get email for sender
343     */
344    protected String getSender(Map transientVars, WorkflowAwareContent content) throws WorkflowException
345    {
346        User user = getCaller(transientVars, content);
347        return user != null ? user.getEmail() : null;
348    }
349    
350    /**
351     * Get the recipients
352     * @param transientVars the transient variables
353     * @param content the content.
354     * @param rights the set of rights to check.
355     * @return the recipients.
356     * @throws WorkflowException If failed to get recipients
357     */
358    protected Map<String, Set<String>> getRecipientsByLanguage(Map transientVars, WorkflowAwareContent content, Set<String> rights) throws WorkflowException
359    {
360        Set<UserIdentity> users = _getUsers(content, rights);
361        
362        String defaultLanguage = _userLanguagesManager.getDefaultLanguage();
363        
364        return users.stream()
365            .map(_userManager::getUser)
366            .filter(Objects::nonNull)
367            .map(user -> Pair.of(user.getLanguage(), user.getEmail()))
368            .filter(p -> StringUtils.isNotEmpty(p.getRight()))
369            .collect(Collectors.groupingBy(
370                    p -> {
371                        return StringUtils.defaultIfBlank(p.getLeft(), defaultLanguage);
372                    },
373                    Collectors.mapping(
374                            Pair::getRight,
375                            Collectors.toSet()
376                    )
377                )
378            );
379    }
380    
381    /**
382     * Get the user logins.
383     * @param content the content.
384     * @param rights the set of rights to check.
385     * @return the users.
386     * @throws WorkflowException If an error occurred
387     */
388    protected Set<UserIdentity> _getUsers(WorkflowAwareContent content, Set<String> rights) throws WorkflowException
389    {
390        Set<UserIdentity> users = new HashSet<>();
391        
392        Iterator<String> rightIt = rights.iterator();
393        
394        // First right : add all the granted users.
395        if (rightIt.hasNext())
396        {
397            users.addAll(_rightManager.getAllowedUsers(rightIt.next(), content).resolveAllowedUsers(Config.getInstance().getValue("runtime.mail.massive.sending")));
398        }
399        
400        // Next rights : retain all the granted users.
401        while (rightIt.hasNext())
402        {
403            users.retainAll(_rightManager.getAllowedUsers(rightIt.next(), content).resolveAllowedUsers(Config.getInstance().getValue("runtime.mail.massive.sending")));
404        }
405        
406        return users;
407    }
408    
409    @Override
410    public FunctionType getFunctionExecType()
411    {
412        return FunctionType.POST;
413    }
414
415    @Override
416    public I18nizableText getLabel()
417    {
418        return new I18nizableText("plugin.cms", "PLUGINS_CMS_SEND_MAIL_FUNCTION_LABEL");
419    }
420    
421    @SuppressWarnings("unchecked")
422    @Override
423    public List<WorkflowArgument> getArguments()
424    {
425        WorkflowArgument rights = WorkflowElementDefinitionHelper.getElementDefinition(
426                RIGHTS_KEY,
427                new I18nizableText("plugin.cms", "PLUGINS_CMS_SEND_MAIL_ARGUMENT_RIGHTS_KEY_LABEL"),
428                new I18nizableText("plugin.cms", "PLUGINS_CMS_SEND_MAIL_ARGUMENT_RIGHTS_KEY_DESCRIPTION"),
429                false,
430                true
431            );
432        StaticEnumerator<String> rightsStaticEnumerator = new StaticEnumerator<>();
433        for (String rightId : _rightsExtensionPoint.getExtensionsIds())
434        {
435            Right right = _rightsExtensionPoint.getExtension(rightId);
436            Map<String, I18nizableTextParameter> params = Map.of("category", right.getCategory(), "label", right.getLabel());
437            rightsStaticEnumerator.add(new I18nizableText("plugin.workflow", "PLUGINS_WORKFLOW_EDITOR_CHECK_RIGHTS_ARGUMENT_RIGHT_KEY_PARAMS_LABEL", params), right.getId());
438        }
439        rights.setEnumerator(rightsStaticEnumerator);
440        
441        return List.of(
442            WorkflowElementDefinitionHelper.getElementDefinition(
443                SUBJECT_KEY,
444                new I18nizableText("plugin.cms", "PLUGINS_CMS_SEND_MAIL_ARGUMENT_SUBJECT_KEY_LABEL"),
445                new I18nizableText("plugin.cms", "PLUGINS_CMS_SEND_MAIL_ARGUMENT_SUBJECT_KEY_DESCRIPTION"),
446                true,
447                false
448            ),
449            WorkflowElementDefinitionHelper.getElementDefinition(
450                BODY_KEY,
451                new I18nizableText("plugin.cms", "PLUGINS_CMS_SEND_MAIL_ARGUMENT_BODY_KEY_LABEL"),
452                new I18nizableText("plugin.cms", "PLUGINS_CMS_SEND_MAIL_ARGUMENT_BODY_KEY_DESCRIPTION"),
453                true,
454                false
455            ),
456            rights
457        );
458    }
459
460    @Override
461    public I18nizableText getFullLabel(Map<String, String> argumentsValues)
462    {
463        String rightsParam = StringUtils.defaultString(argumentsValues.get(RIGHTS_KEY));
464        if (!rightsParam.isBlank())
465        {
466            Object[] rightsIds = _getRights(rightsParam).toArray();
467            Right right = _rightsExtensionPoint.getExtension((String) rightsIds[0]);
468            String concatenatedRights = "<strong>" + _i18nUtils.translate(right.getLabel()) + "</strong>";
469            String and = _i18nUtils.translate(new I18nizableText("plugin.cms", "PLUGINS_CMS_CONDITION_AND"));
470            for (int i = 1; i < rightsIds.length; i++)
471            {
472                right = _rightsExtensionPoint.getExtension((String) rightsIds[i]);
473                concatenatedRights += and + "<strong>" + _i18nUtils.translate(right.getLabel()) + "</strong>";
474            }
475            return new I18nizableText("plugin.cms", "PLUGINS_CMS_SEND_MAIL_FUNCTION_RIGHTS_DESCRIPTION", List.of(concatenatedRights));
476        }
477        return getLabel();
478    }
479}