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