ICalService.java
/*
* Copyright (c) 2002-2022, City of Paris
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice
* and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice
* and the following disclaimer in the documentation and/or other materials
* provided with the distribution.
*
* 3. Neither the name of 'Mairie de Paris' nor 'Lutece' nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*
* License 1.0
*/
package fr.paris.lutece.plugins.workflow.modules.appointment.service;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.ZoneId;
import java.util.StringTokenizer;
import org.apache.commons.lang3.StringUtils;
import fr.paris.lutece.plugins.appointment.business.appointment.Appointment;
import fr.paris.lutece.plugins.appointment.web.dto.AppointmentDTO;
import fr.paris.lutece.portal.service.mail.MailService;
import fr.paris.lutece.portal.service.spring.SpringContextService;
import fr.paris.lutece.portal.service.util.AppLogService;
import fr.paris.lutece.portal.service.util.AppPathService;
import fr.paris.lutece.portal.service.util.AppPropertiesService;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import net.fortuna.ical4j.data.CalendarBuilder;
import net.fortuna.ical4j.data.ParserException;
import net.fortuna.ical4j.model.Calendar;
import net.fortuna.ical4j.model.DateTime;
import net.fortuna.ical4j.model.ParameterList;
import net.fortuna.ical4j.model.TimeZone;
import net.fortuna.ical4j.model.TimeZoneRegistry;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.model.parameter.Cn;
import net.fortuna.ical4j.model.parameter.PartStat;
import net.fortuna.ical4j.model.parameter.Role;
import net.fortuna.ical4j.model.parameter.Rsvp;
import net.fortuna.ical4j.model.parameter.XParameter;
import net.fortuna.ical4j.model.property.Attendee;
import net.fortuna.ical4j.model.property.CalScale;
import net.fortuna.ical4j.model.property.Description;
import net.fortuna.ical4j.model.property.DtEnd;
import net.fortuna.ical4j.model.property.DtStart;
import net.fortuna.ical4j.model.property.Location;
import net.fortuna.ical4j.model.property.Method;
import net.fortuna.ical4j.model.property.Organizer;
import net.fortuna.ical4j.model.property.ProdId;
import net.fortuna.ical4j.model.property.Summary;
import net.fortuna.ical4j.model.property.Uid;
import net.fortuna.ical4j.model.property.Version;
import net.fortuna.ical4j.model.property.XProperty;
/**
* Service to send iCal appointments by email
*/
public class ICalService
{
/**
* The name of the bean of this service
*/
public static final String BEAN_NAME = "workflow-appointment.iCalService";
// properties
private static final String PROPERTY_MAIL_LIST_SEPARATOR = "mail.list.separator";
private static final String PROPERTY_ICAL_PRODID = "workflow-appointment.ical.prodid";
private static final String PROPERTY_DEFAULT_TIME_ZONE = "workflow-appointment.server.timezone.id";
private static final String PROPERTY_RELATIVE_PATH_TO_TIME_ZONE_FILE = "workflow-appointment.server.timezone.fileRelativePath";
// constants
private static final String CONSTANT_MAILTO = "MAILTO:";
// messages
private static final String MSG_TIMEZONE_FILE_NOT_FOUND = "iCal default Time zone file not found";
private static final String MSG_TIMEZONE_FILE_INCORRECT = "iCal default Time zone file format problem";
/**
* Get an instance of the service
*
* @return An instance of the bean of this service
*/
public static ICalService getService( )
{
return SpringContextService.getBean( BEAN_NAME );
}
/**
* Send an appointment to a user by email.
*
* @param strEmailAttendee
* Comma separated list of users that will attend the appointment
* @param strEmailOptionnal
* Comma separated list of users that will be invited to the appointment, but who are not required.
* @param strSubject
* The subject of the appointment.
* @param strBodyContent
* The body content that describes the appointment
* @param strLocation
* The location of the appointment
* @param strSenderName
* The name of the sender
* @param strSenderEmail
* The email of the sender
* @param appointment
* The appointment
* @param bCreate
* True to notify the creation of the appointment, false to notify its removal
*/
public void sendAppointment( String strEmailAttendee, String strEmailOptionnal, String strSubject, String strBodyContent, String strLocation,
String strSenderName, String strSenderEmail, AppointmentDTO appointment, boolean bCreate )
{
CalendarBuilder builder = new CalendarBuilder( );
Calendar iCalendar;
try
{
String strRelativeWebPath = AppPropertiesService.getProperty( PROPERTY_RELATIVE_PATH_TO_TIME_ZONE_FILE );
String absoluteTimeZoneFilePath = AppPathService.getAbsolutePathFromRelativePath( strRelativeWebPath );
iCalendar = builder.build( new FileInputStream( absoluteTimeZoneFilePath ) );
}
catch( FileNotFoundException ex )
{
AppLogService.error( MSG_TIMEZONE_FILE_NOT_FOUND, ex );
return;
}
catch( IOException | ParserException ex )
{
AppLogService.error( MSG_TIMEZONE_FILE_INCORRECT, ex );
return;
}
TimeZoneRegistry registry = builder.getRegistry( );
TimeZone timeZone = registry.getTimeZone( AppPropertiesService.getProperty( PROPERTY_DEFAULT_TIME_ZONE ) );
DateTime beginningDateTime = new DateTime( appointment.getStartingDateTime( ).atZone( ZoneId.systemDefault( ) ).toInstant( ).toEpochMilli( ) );
DateTime endingDateTime = new DateTime( appointment.getEndingDateTime( ).atZone( ZoneId.systemDefault( ) ).toInstant( ).toEpochMilli( ) );
DtStart dtStart = new DtStart( beginningDateTime );
dtStart.setTimeZone( timeZone );
DtEnd dtEnd = new DtEnd( endingDateTime );
dtEnd.setTimeZone( timeZone );
VEvent event = new VEvent( );
event.getProperties( ).add( dtStart );
event.getProperties( ).add( dtEnd );
event.getProperties( ).add( new Summary( ( strSubject != null ) ? strSubject : StringUtils.EMPTY ) );
// Format the description that goes in the ICalendar
String formatedIcalendarDescription = formatICalendarDescription( strBodyContent );
try
{
event.getProperties( ).add( new Uid( Appointment.APPOINTMENT_RESOURCE_TYPE + appointment.getIdAppointment( ) ) );
String strEmailSeparator = AppPropertiesService.getProperty( PROPERTY_MAIL_LIST_SEPARATOR, ";" );
if ( StringUtils.isNotEmpty( strEmailAttendee ) )
{
StringTokenizer st = new StringTokenizer( strEmailAttendee, strEmailSeparator );
while ( st.hasMoreTokens( ) )
{
addAttendee( event, st.nextToken( ), true );
}
}
if ( StringUtils.isNotEmpty( strEmailOptionnal ) )
{
StringTokenizer st = new StringTokenizer( strEmailOptionnal, strEmailSeparator );
while ( st.hasMoreTokens( ) )
{
addAttendee( event, st.nextToken( ), false );
}
}
Organizer organizer = new Organizer( strSenderEmail );
organizer.getParameters( ).add( new Cn( strSenderName ) );
event.getProperties( ).add( organizer );
event.getProperties( ).add( new Location( strLocation ) );
event.getProperties( ).add( new Description( formatedIcalendarDescription ) );
// Add an alternative description to properly render HTML content
addAlternativeHtmlDescription( event, formatedIcalendarDescription );
}
catch( URISyntaxException e )
{
AppLogService.error( e.getMessage( ), e );
}
iCalendar.getProperties( ).add( bCreate ? Method.REQUEST : Method.CANCEL );
iCalendar.getProperties( ).add( new ProdId( AppPropertiesService.getProperty( PROPERTY_ICAL_PRODID ) ) );
iCalendar.getProperties( ).add( Version.VERSION_2_0 );
iCalendar.getProperties( ).add( CalScale.GREGORIAN );
iCalendar.getComponents( ).add( event );
MailService.sendMailCalendar( strEmailAttendee, strEmailOptionnal, null, strSenderName, strSenderEmail,
( strSubject != null ) ? strSubject : StringUtils.EMPTY, strBodyContent, iCalendar.toString( ), bCreate );
}
/**
* Add an attendee to an event
*
* @param event
* The event to add the attendee to
* @param strEmail
* The email of the user
* @param bRequired
* True if the presence of the user is mandatory, false if it is optional
*/
private void addAttendee( VEvent event, String strEmail, boolean bRequired )
{
Attendee attendee = new Attendee( URI.create( CONSTANT_MAILTO + strEmail ) );
attendee.getParameters( ).add( bRequired ? Role.REQ_PARTICIPANT : Role.OPT_PARTICIPANT );
attendee.getParameters( ).add( PartStat.NEEDS_ACTION );
attendee.getParameters( ).add( Rsvp.FALSE );
event.getProperties( ).add( attendee );
}
/**
* Format the String containing the description of a calendar invite to respect the ICalendar specifications: 75 characters per line, CRLF + white-space at
* the start of new lines... ( c.f. <a href="https://datatracker.ietf.org/doc/html/rfc5545#section-3.1">iCalendar RFC5545</a> )
*
* @param strDescription
* The calendar's invite description to format
* @return the formated description, or the value of the original description if the formatting was not necessary
*/
public static String formatICalendarDescription( String strDescription )
{
// ICalendar specific properties. The folding symbol is a return to next line + a white-space
String foldingSymbol = "\r ";
int lineMaxLength = 75;
int descriptionLength = strDescription.length( );
if ( descriptionLength <= lineMaxLength )
{
return strDescription;
}
strDescription = strDescription.replaceAll( "\n", foldingSymbol );
StringBuilder formatedDesctiption = new StringBuilder( strDescription.substring( 0, lineMaxLength ) );
// Use ICalendar's folding method ( CRLF + white-space ) after every 75 characters
for ( int i = lineMaxLength; i < descriptionLength; i += lineMaxLength )
{
if ( i + lineMaxLength < descriptionLength )
{
// Add the folding symbol followed by the next 75 characters of the description
formatedDesctiption.append( foldingSymbol ).append( strDescription.substring( i, i + lineMaxLength ) );
}
else
{
// Add the remaining characters and stop the process
formatedDesctiption.append( foldingSymbol ).append( strDescription.substring( i ) );
break;
}
}
return formatedDesctiption.toString( );
}
/**
* Add an alternative description to process and render HTML content properly. This should only be used if there is HTML in the description
*
* @param event
* The Calendar Event being created
* @param description
* The description of the Event
*/
public void addAlternativeHtmlDescription( VEvent event, String description )
{
// HTML regex pattern
String patternHtml = "[\\S\\s]*\\<\\D+[\\S\\s]*\\>[\\S\\s]*\\<\\/\\D+[\\S\\s]*\\>[\\S\\s]*";
// Check if the description contains HTML elements
if ( description.matches( patternHtml ) )
{
// Create the alternative calendar description with the "X-ALT-DESC" property
ParameterList htmlParameters = new ParameterList( );
XParameter fmtTypeParameter = new XParameter( "FMTTYPE", "text/html" );
htmlParameters.add( fmtTypeParameter );
XProperty htmlProp = new XProperty( "X-ALT-DESC", htmlParameters, description );
// Add the alternative description to the event
event.getProperties( ).add( htmlProp );
}
}
}