View Javadoc
1   /*
2    * Copyright (c) 2002-2022, City of Paris
3    * All rights reserved.
4    *
5    * Redistribution and use in source and binary forms, with or without
6    * modification, are permitted provided that the following conditions
7    * are met:
8    *
9    *  1. Redistributions of source code must retain the above copyright notice
10   *     and the following disclaimer.
11   *
12   *  2. Redistributions in binary form must reproduce the above copyright notice
13   *     and the following disclaimer in the documentation and/or other materials
14   *     provided with the distribution.
15   *
16   *  3. Neither the name of 'Mairie de Paris' nor 'Lutece' nor the names of its
17   *     contributors may be used to endorse or promote products derived from
18   *     this software without specific prior written permission.
19   *
20   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21   * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22   * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
23   * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
24   * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
25   * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
26   * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
27   * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
28   * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
29   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
30   * POSSIBILITY OF SUCH DAMAGE.
31   *
32   * License 1.0
33   */
34  package fr.paris.lutece.plugins.workflow.modules.appointment.service;
35  
36  import java.net.URI;
37  import java.net.URISyntaxException;
38  import java.time.ZoneId;
39  import java.util.StringTokenizer;
40  
41  import org.apache.commons.lang3.StringUtils;
42  
43  import fr.paris.lutece.plugins.appointment.business.appointment.Appointment;
44  import fr.paris.lutece.plugins.appointment.web.dto.AppointmentDTO;
45  import fr.paris.lutece.portal.service.mail.MailService;
46  import fr.paris.lutece.portal.service.spring.SpringContextService;
47  import fr.paris.lutece.portal.service.util.AppLogService;
48  import fr.paris.lutece.portal.service.util.AppPathService;
49  import fr.paris.lutece.portal.service.util.AppPropertiesService;
50  import java.io.FileInputStream;
51  import java.io.FileNotFoundException;
52  import java.io.IOException;
53  import net.fortuna.ical4j.data.CalendarBuilder;
54  import net.fortuna.ical4j.data.ParserException;
55  import net.fortuna.ical4j.model.Calendar;
56  import net.fortuna.ical4j.model.DateTime;
57  import net.fortuna.ical4j.model.ParameterList;
58  import net.fortuna.ical4j.model.TimeZone;
59  import net.fortuna.ical4j.model.TimeZoneRegistry;
60  import net.fortuna.ical4j.model.component.VEvent;
61  import net.fortuna.ical4j.model.parameter.Cn;
62  import net.fortuna.ical4j.model.parameter.PartStat;
63  import net.fortuna.ical4j.model.parameter.Role;
64  import net.fortuna.ical4j.model.parameter.Rsvp;
65  import net.fortuna.ical4j.model.parameter.XParameter;
66  import net.fortuna.ical4j.model.property.Attendee;
67  import net.fortuna.ical4j.model.property.CalScale;
68  import net.fortuna.ical4j.model.property.Description;
69  import net.fortuna.ical4j.model.property.DtEnd;
70  import net.fortuna.ical4j.model.property.DtStart;
71  import net.fortuna.ical4j.model.property.Location;
72  import net.fortuna.ical4j.model.property.Method;
73  import net.fortuna.ical4j.model.property.Organizer;
74  import net.fortuna.ical4j.model.property.ProdId;
75  import net.fortuna.ical4j.model.property.Summary;
76  import net.fortuna.ical4j.model.property.Uid;
77  import net.fortuna.ical4j.model.property.Version;
78  import net.fortuna.ical4j.model.property.XProperty;
79  
80  /**
81   * Service to send iCal appointments by email
82   */
83  public class ICalService
84  {
85      /**
86       * The name of the bean of this service
87       */
88      public static final String BEAN_NAME = "workflow-appointment.iCalService";
89  
90      // properties
91      private static final String PROPERTY_MAIL_LIST_SEPARATOR = "mail.list.separator";
92      private static final String PROPERTY_ICAL_PRODID = "workflow-appointment.ical.prodid";
93      private static final String PROPERTY_DEFAULT_TIME_ZONE = "workflow-appointment.server.timezone.id";
94      private static final String PROPERTY_RELATIVE_PATH_TO_TIME_ZONE_FILE = "workflow-appointment.server.timezone.fileRelativePath";
95  
96      // constants
97      private static final String CONSTANT_MAILTO = "MAILTO:";
98  
99      // messages
100     private static final String MSG_TIMEZONE_FILE_NOT_FOUND = "iCal default Time zone file not found";
101     private static final String MSG_TIMEZONE_FILE_INCORRECT = "iCal default Time zone file format problem";
102 
103     /**
104      * Get an instance of the service
105      * 
106      * @return An instance of the bean of this service
107      */
108     public static ICalService getService( )
109     {
110         return SpringContextService.getBean( BEAN_NAME );
111     }
112 
113     /**
114      * Send an appointment to a user by email.
115      * 
116      * @param strEmailAttendee
117      *            Comma separated list of users that will attend the appointment
118      * @param strEmailOptionnal
119      *            Comma separated list of users that will be invited to the appointment, but who are not required.
120      * @param strSubject
121      *            The subject of the appointment.
122      * @param strBodyContent
123      *            The body content that describes the appointment
124      * @param strLocation
125      *            The location of the appointment
126      * @param strSenderName
127      *            The name of the sender
128      * @param strSenderEmail
129      *            The email of the sender
130      * @param appointment
131      *            The appointment
132      * @param bCreate
133      *            True to notify the creation of the appointment, false to notify its removal
134      */
135     public void sendAppointment( String strEmailAttendee, String strEmailOptionnal, String strSubject, String strBodyContent, String strLocation,
136             String strSenderName, String strSenderEmail, AppointmentDTO appointment, boolean bCreate )
137     {
138 
139         CalendarBuilder builder = new CalendarBuilder( );
140         Calendar iCalendar;
141         try
142         {
143             String strRelativeWebPath = AppPropertiesService.getProperty( PROPERTY_RELATIVE_PATH_TO_TIME_ZONE_FILE );
144             String absoluteTimeZoneFilePath = AppPathService.getAbsolutePathFromRelativePath( strRelativeWebPath );
145             iCalendar = builder.build( new FileInputStream( absoluteTimeZoneFilePath ) );
146         }
147         catch( FileNotFoundException ex )
148         {
149             AppLogService.error( MSG_TIMEZONE_FILE_NOT_FOUND, ex );
150             return;
151         }
152         catch( IOException | ParserException ex )
153         {
154             AppLogService.error( MSG_TIMEZONE_FILE_INCORRECT, ex );
155             return;
156         }
157 
158         TimeZoneRegistry registry = builder.getRegistry( );
159         TimeZone timeZone = registry.getTimeZone( AppPropertiesService.getProperty( PROPERTY_DEFAULT_TIME_ZONE ) );
160 
161         DateTime beginningDateTime = new DateTime( appointment.getStartingDateTime( ).atZone( ZoneId.systemDefault( ) ).toInstant( ).toEpochMilli( ) );
162         DateTime endingDateTime = new DateTime( appointment.getEndingDateTime( ).atZone( ZoneId.systemDefault( ) ).toInstant( ).toEpochMilli( ) );
163 
164         DtStart dtStart = new DtStart( beginningDateTime );
165         dtStart.setTimeZone( timeZone );
166 
167         DtEnd dtEnd = new DtEnd( endingDateTime );
168         dtEnd.setTimeZone( timeZone );
169 
170         VEvent event = new VEvent( );
171         event.getProperties( ).add( dtStart );
172         event.getProperties( ).add( dtEnd );
173         event.getProperties( ).add( new Summary( ( strSubject != null ) ? strSubject : StringUtils.EMPTY ) );
174 
175         // Format the description that goes in the ICalendar
176         String formatedIcalendarDescription = formatICalendarDescription( strBodyContent );
177 
178         try
179         {
180             event.getProperties( ).add( new Uid( Appointment.APPOINTMENT_RESOURCE_TYPE + appointment.getIdAppointment( ) ) );
181             String strEmailSeparator = AppPropertiesService.getProperty( PROPERTY_MAIL_LIST_SEPARATOR, ";" );
182             if ( StringUtils.isNotEmpty( strEmailAttendee ) )
183             {
184                 StringTokenizer st = new StringTokenizer( strEmailAttendee, strEmailSeparator );
185                 while ( st.hasMoreTokens( ) )
186                 {
187                     addAttendee( event, st.nextToken( ), true );
188                 }
189             }
190             if ( StringUtils.isNotEmpty( strEmailOptionnal ) )
191             {
192                 StringTokenizer st = new StringTokenizer( strEmailOptionnal, strEmailSeparator );
193                 while ( st.hasMoreTokens( ) )
194                 {
195                     addAttendee( event, st.nextToken( ), false );
196                 }
197             }
198             Organizer organizer = new Organizer( strSenderEmail );
199             organizer.getParameters( ).add( new Cn( strSenderName ) );
200             event.getProperties( ).add( organizer );
201             event.getProperties( ).add( new Location( strLocation ) );
202             event.getProperties( ).add( new Description( formatedIcalendarDescription ) );
203             // Add an alternative description to properly render HTML content
204             addAlternativeHtmlDescription( event, formatedIcalendarDescription );
205         }
206         catch( URISyntaxException e )
207         {
208             AppLogService.error( e.getMessage( ), e );
209         }
210 
211         iCalendar.getProperties( ).add( bCreate ? Method.REQUEST : Method.CANCEL );
212         iCalendar.getProperties( ).add( new ProdId( AppPropertiesService.getProperty( PROPERTY_ICAL_PRODID ) ) );
213         iCalendar.getProperties( ).add( Version.VERSION_2_0 );
214         iCalendar.getProperties( ).add( CalScale.GREGORIAN );
215         iCalendar.getComponents( ).add( event );
216 
217         MailService.sendMailCalendar( strEmailAttendee, strEmailOptionnal, null, strSenderName, strSenderEmail,
218                 ( strSubject != null ) ? strSubject : StringUtils.EMPTY, strBodyContent, iCalendar.toString( ), bCreate );
219     }
220 
221     /**
222      * Add an attendee to an event
223      * 
224      * @param event
225      *            The event to add the attendee to
226      * @param strEmail
227      *            The email of the user
228      * @param bRequired
229      *            True if the presence of the user is mandatory, false if it is optional
230      */
231     private void addAttendee( VEvent event, String strEmail, boolean bRequired )
232     {
233         Attendee attendee = new Attendee( URI.create( CONSTANT_MAILTO + strEmail ) );
234         attendee.getParameters( ).add( bRequired ? Role.REQ_PARTICIPANT : Role.OPT_PARTICIPANT );
235         attendee.getParameters( ).add( PartStat.NEEDS_ACTION );
236         attendee.getParameters( ).add( Rsvp.FALSE );
237         event.getProperties( ).add( attendee );
238     }
239 
240     /**
241      * Format the String containing the description of a calendar invite to respect the ICalendar specifications: 75 characters per line, CRLF + white-space at
242      * the start of new lines... ( c.f. <a href="https://datatracker.ietf.org/doc/html/rfc5545#section-3.1">iCalendar RFC5545</a> )
243      * 
244      * @param strDescription
245      *            The calendar's invite description to format
246      * @return the formated description, or the value of the original description if the formatting was not necessary
247      */
248     public static String formatICalendarDescription( String strDescription )
249     {
250         // ICalendar specific properties. The folding symbol is a return to next line + a white-space
251         String foldingSymbol = "\r ";
252         int lineMaxLength = 75;
253 
254         int descriptionLength = strDescription.length( );
255 
256         if ( descriptionLength <= lineMaxLength )
257         {
258             return strDescription;
259         }
260 
261         strDescription = strDescription.replaceAll( "\n", foldingSymbol );
262 
263         StringBuilder formatedDesctiption = new StringBuilder( strDescription.substring( 0, lineMaxLength ) );
264 
265         // Use ICalendar's folding method ( CRLF + white-space ) after every 75 characters
266         for ( int i = lineMaxLength; i < descriptionLength; i += lineMaxLength )
267         {
268             if ( i + lineMaxLength < descriptionLength )
269             {
270                 // Add the folding symbol followed by the next 75 characters of the description
271                 formatedDesctiption.append( foldingSymbol ).append( strDescription.substring( i, i + lineMaxLength ) );
272             }
273             else
274             {
275                 // Add the remaining characters and stop the process
276                 formatedDesctiption.append( foldingSymbol ).append( strDescription.substring( i ) );
277                 break;
278             }
279         }
280         return formatedDesctiption.toString( );
281     }
282 
283     /**
284      * Add an alternative description to process and render HTML content properly. This should only be used if there is HTML in the description
285      * 
286      * @param event
287      *            The Calendar Event being created
288      * @param description
289      *            The description of the Event
290      */
291     public void addAlternativeHtmlDescription( VEvent event, String description )
292     {
293         // HTML regex pattern
294         String patternHtml = "[\\S\\s]*\\<\\D+[\\S\\s]*\\>[\\S\\s]*\\<\\/\\D+[\\S\\s]*\\>[\\S\\s]*";
295 
296         // Check if the description contains HTML elements
297         if ( description.matches( patternHtml ) )
298         {
299             // Create the alternative calendar description with the "X-ALT-DESC" property
300             ParameterList htmlParameters = new ParameterList( );
301             XParameter fmtTypeParameter = new XParameter( "FMTTYPE", "text/html" );
302             htmlParameters.add( fmtTypeParameter );
303             XProperty htmlProp = new XProperty( "X-ALT-DESC", htmlParameters, description );
304 
305             // Add the alternative description to the event
306             event.getProperties( ).add( htmlProp );
307         }
308     }
309 }