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