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.alerts;
017
018import java.io.IOException;
019import java.time.LocalDate;
020import java.time.ZonedDateTime;
021import java.util.ArrayList;
022import java.util.Calendar;
023import java.util.Collections;
024import java.util.Date;
025import java.util.GregorianCalendar;
026import java.util.HashMap;
027import java.util.HashSet;
028import java.util.List;
029import java.util.Map;
030import java.util.Set;
031
032import org.apache.avalon.framework.activity.Initializable;
033import org.apache.avalon.framework.configuration.Configuration;
034import org.apache.avalon.framework.configuration.ConfigurationException;
035import org.apache.avalon.framework.service.ServiceException;
036import org.apache.avalon.framework.service.ServiceManager;
037import org.apache.cocoon.components.ContextHelper;
038import org.apache.cocoon.environment.Request;
039import org.apache.commons.lang3.StringUtils;
040import org.quartz.JobDataMap;
041import org.quartz.JobExecutionContext;
042
043import org.ametys.cms.content.ContentHelper;
044import org.ametys.cms.content.archive.ArchiveConstants;
045import org.ametys.cms.repository.Content;
046import org.ametys.cms.repository.ContentQueryHelper;
047import org.ametys.cms.repository.ModifiableContent;
048import org.ametys.cms.repository.WorkflowStepExpression;
049import org.ametys.core.right.RightManager;
050import org.ametys.core.schedule.progression.ContainerProgressionTracker;
051import org.ametys.core.ui.mail.StandardMailBodyHelper;
052import org.ametys.core.ui.mail.StandardMailBodyHelper.MailBodyBuilder;
053import org.ametys.core.user.CurrentUserProvider;
054import org.ametys.core.user.User;
055import org.ametys.core.user.UserIdentity;
056import org.ametys.core.user.population.PopulationContextHelper;
057import org.ametys.core.util.DateUtils;
058import org.ametys.core.util.I18nUtils;
059import org.ametys.core.util.LambdaUtils;
060import org.ametys.core.util.LambdaUtils.LambdaException;
061import org.ametys.core.util.mail.SendMailHelper;
062import org.ametys.plugins.core.impl.schedule.AbstractStaticSchedulable;
063import org.ametys.plugins.core.schedule.Scheduler;
064import org.ametys.plugins.repository.AmetysObjectIterable;
065import org.ametys.plugins.repository.AmetysObjectResolver;
066import org.ametys.plugins.repository.AmetysRepositoryException;
067import org.ametys.plugins.repository.data.holder.ModelLessDataHolder;
068import org.ametys.plugins.repository.data.holder.ModifiableModelLessDataHolder;
069import org.ametys.plugins.repository.query.expression.AndExpression;
070import org.ametys.plugins.repository.query.expression.BooleanExpression;
071import org.ametys.plugins.repository.query.expression.DateExpression;
072import org.ametys.plugins.repository.query.expression.Expression;
073import org.ametys.plugins.repository.query.expression.Expression.LogicalOperator;
074import org.ametys.plugins.repository.query.expression.Expression.Operator;
075import org.ametys.plugins.repository.query.expression.ExpressionContext;
076import org.ametys.plugins.repository.query.expression.MetadataExpression;
077import org.ametys.plugins.repository.query.expression.NotExpression;
078import org.ametys.plugins.repository.query.expression.OrExpression;
079import org.ametys.plugins.repository.version.DataAndVersionAwareAmetysObject;
080import org.ametys.plugins.repository.version.ModifiableDataAwareVersionableAmetysObject;
081import org.ametys.runtime.config.Config;
082import org.ametys.runtime.i18n.I18nizableText;
083
084import jakarta.mail.MessagingException;
085
086/**
087 * Alerts engine: sends alerts mail.
088 */
089public class AlertSchedulable extends AbstractStaticSchedulable implements Initializable
090{
091    /** The schedulable id */
092    public static final String SCHEDULABLE_ID = AlertSchedulable.class.getName();
093    
094    /** The job context param to set to true when using instantMode */
095    public static final String JOBDATAMAP_INSTANT_MODE_KEY = "instantMode";
096    
097    /** The job context param to specify the target contents when using instantMode */
098    public static final String JOBDATAMAP_CONTENT_IDS_KEY = "contentIds";
099    
100    /** The job context param to set the message when using instantMode */
101    public static final String JOBDATAMAP_MESSAGE_KEY = "message";
102    
103    /** The context used for validation alerts expressions */
104    protected static final ExpressionContext __VALIDATION_ALERT_EXPR_CONTEXT = ExpressionContext.newInstance().withUnversioned(true);
105    
106    /** The service manager. */
107    protected ServiceManager _manager;
108    
109    /** Is the engine initialized ? */
110    protected boolean _initialized;
111    
112    /** The cocoon environment context. */
113    protected org.apache.cocoon.environment.Context _environmentContext;
114    
115    /** The ametys object resolver. */
116    protected AmetysObjectResolver _resolver;
117    
118    /** The rights manager. */
119    protected RightManager _rightManager;
120    
121    /** The i18n utils. */
122    protected I18nUtils _i18nUtils;
123    
124    /** The content helper */
125    protected ContentHelper _contentHelper;
126    
127    /** The content of "from" field in emails. */
128    protected String _mailFrom;
129    
130    /** The "waiting for validation" e-mail will be sent to users that have at least one of this rights. */
131    protected Set<String> _awaitingValidationRights;
132    /** The "waiting for validation" e-mail subject i18n key. */
133    protected String _awaitingValidationSubject;
134    /** The "waiting for validation" e-mail body i18n key. */
135    protected String _awaitingValidationBody;
136    
137    /** Only contents in this steps will be taken into account for the "unmodified content" alert. */
138    protected int[] _unmodifiedContentStepIds;
139    /** The "unmodified content" e-mail will be sent to users that have at least one of this rights. */
140    protected Set<String> _unmodifiedContentRights;
141    /** The "unmodified content" e-mail subject i18n key. */
142    protected String _unmodifiedContentSubject;
143    /** The "unmodified content" e-mail body i18n key. */
144    protected String _unmodifiedContentBody;
145    
146    /** The reminder e-mail will be sent to users that have this at least one of this rights. */
147    protected Set<String> _reminderRights;
148    /** The reminder e-mail subject i18n key. */
149    protected String _reminderSubject;
150    /** The reminder e-mail body i18n key. */
151    protected String _reminderBody;
152    
153    /** The scheduled archiving reminder e-mail will be sent to users that have this at least one of this rights. */
154    protected Set<String> _scheduledArchivingReminderRights;
155    /** The scheduled archiving reminder e-mail subject i18n key. */
156    protected String _scheduledArchivingReminderSubject;
157    /** The scheduled archiving reminder e-mail body i18n key. */
158    protected String _scheduledArchivingReminderBody;
159   
160    /** The instant alert e-mail will be sent to users that have this at least one of this rights. */
161    protected Set<String> _instantAlertRights;
162    /** The instant alert e-mail subject i18n key. */
163    protected String _instantAlertSubject;
164    /** The instant alert e-mail body i18n key. */
165    protected String _instantAlertBody;
166    
167    /** The current user provider */
168    protected CurrentUserProvider _currentUserProvider;
169
170    @Override
171    public void service(ServiceManager manager) throws ServiceException
172    {
173        super.service(manager);
174        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
175        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
176        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
177        _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE);
178        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
179    }
180    
181    public void initialize() throws Exception
182    {
183        _mailFrom = Config.getInstance().getValue("smtp.mail.from");
184    }
185    
186    /**
187     * Configure the engine (called by the scheduler).
188     * @param configuration the component configuration.
189     * @throws ConfigurationException if the configuration is not valid
190     */
191    @Override
192    public void configure(Configuration configuration) throws ConfigurationException
193    {
194        super.configure(configuration);
195        Configuration instantConf = configuration.getChild("instantAlert");
196        Configuration validationConf = configuration.getChild("awaitingValidation");
197        Configuration unmodifiedConf = configuration.getChild("unmodifiedContent");
198        Configuration reminderConf = configuration.getChild("reminder");
199        Configuration scheduledArchivingReminderConf = configuration.getChild("scheduledArchiving");
200        
201        // Configure the rights.
202        _instantAlertRights = _getRightsFromConf(instantConf);
203        _awaitingValidationRights = _getRightsFromConf(validationConf);
204        _unmodifiedContentRights = _getRightsFromConf(unmodifiedConf);
205        _reminderRights = _getRightsFromConf(reminderConf);
206        _scheduledArchivingReminderRights = _getRightsFromConf(scheduledArchivingReminderConf);
207        Configuration[] stepIds = unmodifiedConf.getChildren("stepId");
208        _unmodifiedContentStepIds = new int[stepIds.length];
209        int i = 0;
210        for (Configuration stepId : stepIds)
211        {
212            try
213            {
214                _unmodifiedContentStepIds[i] = Integer.parseInt(stepId.getValue(""));
215                i++;
216            }
217            catch (NumberFormatException e)
218            {
219                // Ignore
220            }
221        }
222        
223        // Configure the i18n texts.
224        _awaitingValidationSubject = validationConf.getChild("subjectKey").getValue();
225        _awaitingValidationBody = validationConf.getChild("bodyKey").getValue();
226        _unmodifiedContentSubject = unmodifiedConf.getChild("subjectKey").getValue();
227        _unmodifiedContentBody = unmodifiedConf.getChild("bodyKey").getValue();
228        _reminderSubject = reminderConf.getChild("subjectKey").getValue();
229        _reminderBody = reminderConf.getChild("bodyKey").getValue();
230        _scheduledArchivingReminderSubject = scheduledArchivingReminderConf.getChild("subjectKey").getValue();
231        _scheduledArchivingReminderBody = scheduledArchivingReminderConf.getChild("bodyKey").getValue();
232        _instantAlertSubject = instantConf.getChild("subjectKey").getValue();
233        _instantAlertBody = instantConf.getChild("bodyKey").getValue();
234    }
235    
236    /**
237     * Set the necessary request attributes
238     * @param content The content
239     */
240    protected void _setRequestAttributes (Content content)
241    {
242        Request request = ContextHelper.getRequest(_context);
243        
244        List<String> populationContexts = new ArrayList<>();
245        populationContexts.add("/application");
246        
247        request.setAttribute(PopulationContextHelper.POPULATION_CONTEXTS_REQUEST_ATTR, populationContexts);
248    }
249    
250    /**
251     * Send all the alerts. Can be overridden to add alerts.
252     * @throws AmetysRepositoryException if an error occurs.
253     */
254    @Override
255    public void execute(JobExecutionContext context, ContainerProgressionTracker progressionTracker) throws Exception
256    {
257        JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
258        if (isInstantMode(jobDataMap))
259        {
260            @SuppressWarnings("unchecked")
261            List<String> contentIds = (List<String>) jobDataMap.get(Scheduler.PARAM_VALUES_PREFIX + JOBDATAMAP_CONTENT_IDS_KEY);
262            String message = jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + JOBDATAMAP_MESSAGE_KEY);
263            _sendInstantAlerts(contentIds, message);
264        }
265        else
266        {
267            _sendAwaitingValidationAlerts();
268            _sendUnmodifiedAlerts();
269            _sendReminders();
270            _sendScheduledArchivingReminders();
271        }
272    }
273
274    /**
275     * Check if the scheduler was triggered to send an instant alert
276     * @param jobDataMap the job data map
277     * @return true if the scheduler was triggered to send an instant alert
278     */
279    protected boolean isInstantMode(JobDataMap jobDataMap)
280    {
281        boolean instantMode = jobDataMap.getBooleanValue(Scheduler.PARAM_VALUES_PREFIX + JOBDATAMAP_INSTANT_MODE_KEY);
282        return instantMode;
283    }
284    
285    /**
286     * Send instant alerts on contents
287     * @param contentIds The contents to alert on
288     * @param message The custom message to add to the mail
289     * @throws AmetysRepositoryException if an error occurred
290     */
291    protected void _sendInstantAlerts (List<String> contentIds, String message) throws AmetysRepositoryException
292    {
293        if (contentIds != null && !contentIds.isEmpty())
294        {
295            for (String contentId : contentIds)
296            {
297                Content content = _resolver.resolveById(contentId);
298                _sendInstantAlertEmail (content, message);
299            }
300        }
301    }
302    
303    /**
304     * Send a instant e-mail alert to all the users who have the right to edit the content.
305     * @param content the content about which to send the alert.
306     * @param message the message
307     * @throws AmetysRepositoryException if an error occurred
308     */
309    protected void _sendInstantAlertEmail(Content content, String message) throws AmetysRepositoryException
310    {
311        _setRequestAttributes(content);
312        
313        Set<UserIdentity> users = new HashSet<>();
314        for (String right : _instantAlertRights)
315        {
316            users.addAll(_rightManager.getAllowedUsers(right, content).resolveAllowedUsers(Config.getInstance().getValue("runtime.mail.massive.sending")));
317        }
318        
319        List<String> params = _getInstantAlertParams(content);
320        
321        I18nizableText i18nSubject = new I18nizableText(null, getI18nKeyBody(_instantAlertSubject, content), params);
322        I18nizableText i18nBody = new I18nizableText(null, getI18nKeyBody(_instantAlertBody, content), params);
323        
324        try
325        {
326            MailBodyBuilder body = StandardMailBodyHelper.newHTMLBody()
327                    .withTitle(i18nSubject)
328                    .withMessage(i18nBody)
329                    .withDetails(new I18nizableText("plugin.cms", "CONTENT_ALERTS_MAIL_CONTENT_MESSAGE"), message, false)
330                    .withLink(_getContentUrl(content), new I18nizableText("plugin.cms", "CONTENT_ALERTS_MAIL_CONTENT_LINK_TITLE"));
331            
332            _sendMails(i18nSubject, body, users, _mailFrom);
333        }
334        catch (IOException e)
335        {
336            getLogger().error("Fail to build HTML mail body for instant alert", e);
337        }
338    }
339    
340    /**
341     * Send the "awaiting validation" alerts.
342     * @throws AmetysRepositoryException if an error occurs.
343     */
344    protected void _sendAwaitingValidationAlerts() throws AmetysRepositoryException
345    {
346        Long delay = Config.getInstance().getValue("remind.content.validation.delay");
347        if (delay != null && delay > 0)
348        {
349            Calendar calendar = new GregorianCalendar();
350            _removeTimeParts(calendar);
351            calendar.add(Calendar.DAY_OF_MONTH, 1 - delay.intValue());
352            
353            // No last date and X days after the proposal date.
354            Expression noLastDateExpr = new NotExpression(new MetadataExpression(AlertsConstants.AWAITING_VALIDATION_ALERT_LAST_DATE, __VALIDATION_ALERT_EXPR_CONTEXT));
355            Expression waitingExpression = new DateExpression("proposalDate", Operator.LT, calendar.getTime(), __VALIDATION_ALERT_EXPR_CONTEXT);
356            // Or proposal date before the last "awaiting validation" date and the and X days after the last "awaiting validation" date.
357            Expression proposalBeforeLastDateExpr = new BinaryExpression("proposalDate", __VALIDATION_ALERT_EXPR_CONTEXT, Operator.LT, AlertsConstants.AWAITING_VALIDATION_ALERT_LAST_DATE, __VALIDATION_ALERT_EXPR_CONTEXT);
358            Expression lastDateExpr = new DateExpression(AlertsConstants.AWAITING_VALIDATION_ALERT_LAST_DATE, Operator.LT, calendar.getTime(), __VALIDATION_ALERT_EXPR_CONTEXT);
359            Expression expression = new OrExpression(new AndExpression(noLastDateExpr, waitingExpression),
360                                                     new AndExpression(proposalBeforeLastDateExpr, lastDateExpr));
361            
362            String query = ContentQueryHelper.getContentXPathQuery(expression);
363            
364            try (AmetysObjectIterable<ModifiableContent> contents = _resolver.query(query))
365            {
366                if (getLogger().isInfoEnabled())
367                {
368                    getLogger().info("Contents waiting for validation: " + contents.getSize());
369                }
370                
371                for (ModifiableContent content : contents)
372                {
373                    // Send the alert e-mails.
374                    _sendAwaitingValidationEmail(content);
375                    
376                    // Set the last validation alert date to now.
377                    ModifiableModelLessDataHolder dataHolder = ((ModifiableDataAwareVersionableAmetysObject) content).getUnversionedDataHolder();
378                    dataHolder.setValue(AlertsConstants.AWAITING_VALIDATION_ALERT_LAST_DATE, ZonedDateTime.now());
379                    
380                    content.saveChanges();
381                }
382            }
383        }
384    }
385    
386    /**
387     * Send the unmodified content alerts.
388     * @throws AmetysRepositoryException if an error occurs.
389     */
390    protected void _sendUnmodifiedAlerts() throws AmetysRepositoryException
391    {
392        Long delay = Config.getInstance().getValue("remind.unmodified.content.delay");
393        if (delay != null && delay > 0)
394        {
395            Calendar calendar = new GregorianCalendar();
396            _removeTimeParts(calendar);
397            calendar.add(Calendar.DAY_OF_MONTH, 1 - delay.intValue());
398            
399            ExpressionContext exprContext = ExpressionContext.newInstance().withUnversioned(true);
400            
401            // If no step is configured, stepExpr will return the empty string.
402            Expression stepExpr = new WorkflowStepExpression(Operator.EQ, _unmodifiedContentStepIds, LogicalOperator.OR);
403            // Get only the contents on which the alert is enabled.
404            Expression unmodifiedExpr = new BooleanExpression(AlertsConstants.UNMODIFIED_ALERT_ENABLED, true, exprContext);
405            // No last date and X days after the proposal date, or X days after the last date.
406            Expression noLastDateExpr = new NotExpression(new MetadataExpression(AlertsConstants.UNMODIFIED_ALERT_LAST_DATE, exprContext));
407            Expression lastModifiedExpression = new DateExpression("lastModified", Operator.LT, calendar.getTime());
408            Expression lastDateExpr = new DateExpression(AlertsConstants.UNMODIFIED_ALERT_LAST_DATE, Operator.LT, calendar.getTime(), exprContext);
409            Expression dateExpr = new OrExpression(new AndExpression(noLastDateExpr, lastModifiedExpression), lastDateExpr);
410            // Full AND expression.
411            Expression expression = new AndExpression(unmodifiedExpr, dateExpr, stepExpr);
412            
413            String query = ContentQueryHelper.getContentXPathQuery(expression);
414            
415            try (AmetysObjectIterable<ModifiableContent> contents = _resolver.query(query))
416            {
417                if (getLogger().isInfoEnabled())
418                {
419                    getLogger().info("Contents not modified for " + delay + " days: " + contents.getSize());
420                }
421                
422                for (ModifiableContent content : contents)
423                {
424                    // Send the alert e-mail.
425                    _sendUnmodifiedContentEmail(content);
426                    
427                    // Set the last unmodified alert date to now.
428                    ModifiableModelLessDataHolder dataHolder = ((ModifiableDataAwareVersionableAmetysObject) content).getUnversionedDataHolder();
429                    dataHolder.setValue(AlertsConstants.UNMODIFIED_ALERT_LAST_DATE, ZonedDateTime.now());
430                    
431                    content.saveChanges();
432                }
433            }
434        }
435    }
436    
437    /**
438     * Send the content reminders.
439     * @throws AmetysRepositoryException if an error occurs.
440     */
441    protected void _sendReminders() throws AmetysRepositoryException
442    {
443        Date now = DateUtils.asDate(LocalDate.now());
444        
445        ExpressionContext exprContext = ExpressionContext.newInstance().withUnversioned(true);
446        Expression reminderExpr = new BooleanExpression(AlertsConstants.REMINDER_ENABLED, Operator.EQ, true, exprContext);
447        Expression dateExpr = new DateExpression(AlertsConstants.REMINDER_DATE, Operator.EQ, now, exprContext);
448        Expression expression = new AndExpression(reminderExpr, dateExpr);
449        
450        String query = ContentQueryHelper.getContentXPathQuery(expression);
451        
452        try (AmetysObjectIterable<Content> contents = _resolver.query(query))
453        {
454            if (getLogger().isInfoEnabled())
455            {
456                getLogger().info("Contents with reminder today: " + contents.getSize());
457            }
458            
459            for (Content content : contents)
460            {
461                _sendReminderEmail(content);
462            }
463        }
464    }
465    
466    /**
467     * Send the scheduled archiving reminders on contents.
468     * @throws AmetysRepositoryException if an error occurs.
469     */
470    protected void _sendScheduledArchivingReminders() throws AmetysRepositoryException
471    {
472        Long delay = Config.getInstance().getValue("archive.scheduler.reminder.delay");
473        if (delay != null && delay > 0)
474        {
475            Calendar calendar = new GregorianCalendar();
476            _removeTimeParts(calendar);
477            calendar.add(Calendar.DAY_OF_MONTH, delay.intValue());
478            
479            ExpressionContext exprContext = ExpressionContext.newInstance().withUnversioned(true);
480            
481            // No last date and X days before the scheduled date.
482            Expression noLastDateExpr = new NotExpression(new MetadataExpression(AlertsConstants.SCHEDULED_ARCHIVING_REMINDER_LAST_DATE, exprContext));
483            Expression scheduledDelayExpression = new DateExpression(ArchiveConstants.META_ARCHIVE_SCHEDULED_DATE, Operator.LE, calendar.getTime(), exprContext);
484            Expression expression = new AndExpression(noLastDateExpr, scheduledDelayExpression);
485            
486            String query = ContentQueryHelper.getContentXPathQuery(expression);
487            
488            try (AmetysObjectIterable<ModifiableContent> contents = _resolver.query(query))
489            {
490                if (getLogger().isInfoEnabled())
491                {
492                    getLogger().info("Contents with scheduled archiving reminder today: " + contents.getSize());
493                }
494                
495                for (ModifiableContent content : contents)
496                {
497                    _sendScheduledArchivingReminderEmail(content);
498                    
499                    // Set the last scheduled archiving reminder date to now.
500                    ModifiableModelLessDataHolder dataHolder = ((ModifiableDataAwareVersionableAmetysObject) content).getUnversionedDataHolder();
501                    dataHolder.setValue(AlertsConstants.SCHEDULED_ARCHIVING_REMINDER_LAST_DATE, ZonedDateTime.now());
502                    
503                    content.saveChanges();
504                }
505            }
506        }
507    }
508    
509    /**
510     * Send a "waiting for validation" e-mail alert to all the users who have the right to validate.
511     * @param content the content about which to send the alert.
512     * @throws AmetysRepositoryException if an error occured on the repository
513     */
514    protected void _sendAwaitingValidationEmail(Content content) throws AmetysRepositoryException
515    {
516        _setRequestAttributes(content);
517        
518        Set<UserIdentity> users = new HashSet<>();
519        for (String right : _awaitingValidationRights)
520        {
521            users.addAll(_rightManager.getAllowedUsers(right, content).resolveAllowedUsers(Config.getInstance().getValue("runtime.mail.massive.sending")));
522        }
523        
524        List<String> params = _getAwaitingValidationParams(content);
525        
526        I18nizableText i18nSubject = new I18nizableText(null, getI18nKeyBody(_awaitingValidationSubject, content), params);
527        I18nizableText i18nBody = new I18nizableText(null, getI18nKeyBody(_awaitingValidationBody, content), params);
528        
529        try
530        {
531            MailBodyBuilder body = StandardMailBodyHelper.newHTMLBody()
532                    .withTitle(i18nSubject)
533                    .withMessage(i18nBody)
534                    .withLink(_getContentUrl(content), new I18nizableText("plugin.cms", "CONTENT_ALERTS_MAIL_CONTENT_LINK_TITLE"));
535            
536            _sendMails(i18nSubject, body, users, _mailFrom);
537        }
538        catch (IOException e)
539        {
540            getLogger().error("Fail to build HTML mail body for awaiting validation email", e);
541        }
542    }
543    
544    /**
545     * Send a "unmodified content" e-mail alert to all the users who have the right to edit.
546     * @param content the content about which to send the alert.
547     * @throws AmetysRepositoryException if an error occured on the repository
548     */
549    protected void _sendUnmodifiedContentEmail(Content content) throws AmetysRepositoryException
550    {
551        _setRequestAttributes(content);
552        
553        Set<UserIdentity> users = new HashSet<>();
554        for (String right : _unmodifiedContentRights)
555        {
556            users.addAll(_rightManager.getAllowedUsers(right, content).resolveAllowedUsers(Config.getInstance().getValue("runtime.mail.massive.sending")));
557        }
558        
559        List<String> params = _getUnmodifiedContentParams(content);
560        
561        I18nizableText i18nSubject = new I18nizableText(null, getI18nKeyBody(_unmodifiedContentSubject, content), params);
562        I18nizableText i18nBody = new I18nizableText(null, getI18nKeyBody(_unmodifiedContentBody, content), params);
563        
564        try
565        {
566            MailBodyBuilder bodyBuilder = StandardMailBodyHelper.newHTMLBody()
567                    .withTitle(i18nSubject)
568                    .withMessage(i18nBody)
569                    .withLink(_getContentUrl(content), new I18nizableText("plugin.cms", "CONTENT_ALERTS_MAIL_CONTENT_LINK_TITLE"));
570                    
571            ModelLessDataHolder dataHolder = ((DataAndVersionAwareAmetysObject) content).getUnversionedDataHolder();
572            String alertText = dataHolder.getValue(AlertsConstants.UNMODIFIED_ALERT_TEXT, StringUtils.EMPTY);
573              
574            if (StringUtils.isNotEmpty(alertText))
575            {
576                bodyBuilder.withDetails(new I18nizableText("plugin.cms", "CONTENT_ALERTS_MAIL_CONTENT_MESSAGE"), alertText, false);
577            }
578            
579            _sendMails(i18nSubject, bodyBuilder, users, _mailFrom);
580        }
581        catch (IOException e)
582        {
583            getLogger().error("Fail to build HTML mail body for unmodified content alert", e);
584        }
585    }
586    
587    /**
588     * Send a reminder e-mail to all the users who have the right to edit.
589     * @param content the content about which to send the reminder.
590     * @throws AmetysRepositoryException if an error occured on the repository
591     */
592    protected void _sendReminderEmail(Content content) throws AmetysRepositoryException
593    {
594        _setRequestAttributes(content);
595        
596        Set<UserIdentity> users = new HashSet<>();
597        for (String right : _reminderRights)
598        {
599            users.addAll(_rightManager.getAllowedUsers(right, content).resolveAllowedUsers(Config.getInstance().getValue("runtime.mail.massive.sending")));
600        }
601        
602        List<String> params = _getReminderParams(content);
603        
604        I18nizableText i18nSubject = new I18nizableText(null, getI18nKeyBody(_reminderSubject, content), params);
605        I18nizableText i18nBody = new I18nizableText(null, getI18nKeyBody(_reminderBody, content), params);
606        
607        // Should never trigger a ClassCastException, as we query on an unversioned metadata.
608        ModelLessDataHolder dataHolder = ((DataAndVersionAwareAmetysObject) content).getUnversionedDataHolder();
609        String reminderText = dataHolder.getValue(AlertsConstants.REMINDER_TEXT, StringUtils.EMPTY);
610        
611        try
612        {
613            MailBodyBuilder body = StandardMailBodyHelper.newHTMLBody()
614                    .withTitle(i18nSubject)
615                    .withMessage(i18nBody)
616                    .withDetails(new I18nizableText("plugin.cms", "CONTENT_ALERTS_MAIL_CONTENT_MESSAGE"), reminderText, false)
617                    .withLink(_getContentUrl(content), new I18nizableText("plugin.cms", "CONTENT_ALERTS_MAIL_CONTENT_LINK_TITLE"));
618            
619            _sendMails(i18nSubject, body, users, _mailFrom);
620        }
621        catch (IOException e)
622        {
623            getLogger().error("Fail to build HTML mail body for content reminder", e);
624        }
625    }
626    
627    /**
628     * Send a "scheduled archiving reminder" e-mail to all the users who have the right to archive.
629     * @param content the content about which to send the alert.
630     * @throws AmetysRepositoryException if an error occured on the repository
631     */
632    protected void _sendScheduledArchivingReminderEmail(ModifiableContent content) throws AmetysRepositoryException
633    {
634        _setRequestAttributes(content);
635        
636        Set<UserIdentity> users = new HashSet<>();
637        for (String right : _scheduledArchivingReminderRights)
638        {
639            users.addAll(_rightManager.getAllowedUsers(right, content).resolveAllowedUsers(Config.getInstance().getValue("runtime.mail.massive.sending")));
640        }
641        
642        List<String> params = _getScheduledArchivingReminderParams(content);
643        
644        I18nizableText i18nSubject = new I18nizableText(null, getI18nKeyBody(_scheduledArchivingReminderSubject, content), params);
645        I18nizableText i18nBody = new I18nizableText(null, getI18nKeyBody(_scheduledArchivingReminderBody, content), params);
646        
647        try
648        {
649            MailBodyBuilder body = StandardMailBodyHelper.newHTMLBody()
650                    .withTitle(i18nSubject)
651                    .withMessage(i18nBody)
652                    .withLink(_getContentUrl(content), new I18nizableText("plugin.cms", "CONTENT_ALERTS_MAIL_CONTENT_LINK_TITLE"));
653            
654            _sendMails(i18nSubject, body, users, _mailFrom);
655        }
656        catch (IOException e)
657        {
658            getLogger().error("Fail to build HTML mail body for scheduled archiving reminder", e);
659        }
660    }
661    
662    /**
663     * Get the transform i18n body key for specific content
664     * @param bodyI18nKey the original body key
665     * @param content the content
666     * @return the transform i18n body key
667     */
668    protected String getI18nKeyBody(String bodyI18nKey, Content content)
669    {
670        return bodyI18nKey;
671    }
672    
673    /**
674     * Get the mail parameters for instant alert.
675     * @param content the content.
676     * @return the parameters.
677     */
678    protected List<String> _getInstantAlertParams(Content content)
679    {
680        // TODO switch to named params
681        List<String> params = new ArrayList<>();
682        
683        params.add(content.getTitle(null)); // {0}
684        params.add(_getContentUrl(content)); // {1}
685        
686        params.addAll(_getAdditionalParams(content));
687        
688        return params;
689    }
690    
691    /**
692     * Get the additional i18n parameters for content
693     * @param content The content
694     * @return The additional i18n parameters
695     */
696    protected List<String> _getAdditionalParams (Content content)
697    {
698        return Collections.EMPTY_LIST;
699    }
700    
701    /**
702     * Get the mail parameters.
703     * @param content the content.
704     * @return the parameters.
705     */
706    protected List<String> _getAwaitingValidationParams(Content content)
707    {
708        // TODO switch to named params
709        List<String> params = new ArrayList<>();
710        
711        Long delay = Config.getInstance().getValue("remind.content.validation.delay");
712        
713        params.add(content.getTitle(null)); // {0}
714        params.add(_getContentUrl(content)); // {1}
715        params.add(String.valueOf(delay)); // {2}
716        
717        params.addAll(_getAdditionalParams(content));
718        
719        return params;
720    }
721    
722    /**
723     * Get the mail parameters.
724     * @param content the content.
725     * @return the parameters.
726     */
727    protected List<String> _getUnmodifiedContentParams(Content content)
728    {
729        // TODO switch to named params
730        List<String> params = new ArrayList<>();
731        
732        Long delay = Config.getInstance().getValue("remind.unmodified.content.delay");
733        
734        params.add(content.getTitle(null)); // {0}
735        params.add(_getContentUrl(content)); // {1}
736        params.add(String.valueOf(delay));  // {2}
737        
738        params.addAll(_getAdditionalParams(content));
739        
740        return params;
741    }
742    
743    /**
744     * Get the mail parameters.
745     * @param content the content.
746     * @return the parameters.
747     */
748    protected List<String> _getReminderParams(Content content)
749    {
750        // TODO switch to named params
751        List<String> params = new ArrayList<>();
752        
753        params.add(content.getTitle(null)); // {0}
754        params.add(_getContentUrl(content)); // {1}
755        
756        params.addAll(_getAdditionalParams(content));
757        
758        return params;
759    }
760    
761    /**
762     * Get the mail parameters.
763     * @param content the content.
764     * @return the parameters.
765     */
766    protected List<String> _getScheduledArchivingReminderParams(ModifiableContent content)
767    {
768        // TODO switch to named params
769        List<String> params = new ArrayList<>();
770        
771        Long delay = Config.getInstance().getValue("archive.scheduler.reminder.delay");
772         
773        params.add(content.getTitle(null)); // {0}
774        params.add(_getContentUrl(content)); // {1}
775        params.add(String.valueOf(delay)); // {2}
776        
777        params.addAll(_getAdditionalParams(content));
778        
779        return params;
780    }
781    
782    /**
783     * Send the alert emails.
784     * @param i18nSubject the e-mail subject.
785     * @param bodyBuilder the e-mail body.
786     * @param users users to send the mail to.
787     * @param from the address sending the e-mail.
788     * @throws IOException If an error occured while creating the body of the mail
789     */
790    protected void _sendMails(I18nizableText i18nSubject, MailBodyBuilder bodyBuilder, Set<UserIdentity> users, String from) throws IOException
791    {
792        Map<String, String> bodyByLanguage = new HashMap<>();
793        for (UserIdentity identity : users)
794        {
795            User user = _userManager.getUser(identity.getPopulationId(), identity.getLogin());
796            
797            if (user != null && StringUtils.isNotBlank(user.getEmail()))
798            {
799                String mail = user.getEmail();
800                
801                String language = StringUtils.defaultIfBlank(user.getLanguage(), _userLanguagesManager.getDefaultLanguage());
802                
803                String subject = _i18nUtils.translate(i18nSubject, language);
804                String body;
805                try
806                {
807                    body = bodyByLanguage.computeIfAbsent(
808                        language,
809                        // Retrieve the exception as a LambdaException to avoid IOException
810                        LambdaUtils.wrap(lang -> bodyBuilder.withLanguage(lang).build())
811                    );
812                    
813                    SendMailHelper.newMail()
814                                  .withSubject(subject)
815                                  .withHTMLBody(body)
816                                  .withSender(from)
817                                  .withRecipient(mail)
818                                  .sendMail();
819                    
820                }
821                catch (LambdaException e)
822                {
823                    throw (IOException) e.getCause();
824                }
825                catch (MessagingException | IOException e)
826                {
827                    if (getLogger().isWarnEnabled())
828                    {
829                        getLogger().warn("Could not send an alert e-mail to " + mail, e);
830                    }
831                }
832            }
833        }
834    }
835    
836    /**
837     * Get the URL to the given content tool.
838     * @param content the content.
839     * @return the content URL.
840     */
841    protected String _getContentUrl(Content content)
842    {
843        return _contentHelper.getContentBOUrl(content, Map.of());
844    }
845    
846    /**
847     * Remove the time parts from a calendar, leaving only date parts.
848     * @param calendar the calendar.
849     */
850    protected void _removeTimeParts(Calendar calendar)
851    {
852        calendar.set(Calendar.HOUR_OF_DAY, 0);
853        calendar.set(Calendar.MINUTE, 0);
854        calendar.set(Calendar.SECOND, 0);
855        calendar.set(Calendar.MILLISECOND, 0);
856    }
857    
858    /**
859     * Get a set of rights from a configuration.
860     * @param configuration the configuration.
861     * @return the set of rights.
862     * @throws ConfigurationException if the configuration is not valid.
863     */
864    protected Set<String> _getRightsFromConf(Configuration configuration) throws ConfigurationException
865    {
866        Set<String> rights = new HashSet<>();
867        
868        for (Configuration rightConf : configuration.getChildren("right"))
869        {
870            String right = rightConf.getValue("");
871            if (StringUtils.isNotBlank(right))
872            {
873                rights.add(right);
874            }
875        }
876        
877        return rights;
878    }
879    
880    /**
881     * Binary date expression: test on two metadatas.
882     */
883    protected class BinaryExpression implements Expression
884    {
885        private MetadataExpression _metadata1;
886        private MetadataExpression _metadata2;
887        private Operator _operator;
888        
889        /**
890         * Creates the comparison Expression.
891         * @param metadata1 the first metadata name.
892         * @param operator the operator to make the comparison
893         * @param metadata2 the second metadata name.
894         */
895        public BinaryExpression(String metadata1, Operator operator, String metadata2)
896        {
897            _metadata1 = new MetadataExpression(metadata1);
898            _operator = operator;
899            _metadata2 = new MetadataExpression(metadata2);
900        }
901        
902        /**
903         * Creates the comparison Expression.
904         * @param metadata1 the first metadata name.
905         * @param context1 context of the expression for the first metadata
906         * @param operator the operator to make the comparison.
907         * @param metadata2 the second metadata name.
908         * @param context2 context of the expression for the second metadata
909         */
910        public BinaryExpression(String metadata1, ExpressionContext context1, Operator operator, String metadata2, ExpressionContext context2)
911        {
912            _metadata1 = new MetadataExpression(metadata1, context1);
913            _operator = operator;
914            _metadata2 = new MetadataExpression(metadata2, context2);
915        }
916        
917        @Override
918        public String build()
919        {
920            return _metadata1.build() + " " + _operator + " " + _metadata2.build();
921        }
922    }
923    
924}