View Javadoc
1   /*
2    * DavMail POP/IMAP/SMTP/CalDav/LDAP Exchange Gateway
3    * Copyright (C) 2010  Mickael Guessant
4    *
5    * This program is free software; you can redistribute it and/or
6    * modify it under the terms of the GNU General Public License
7    * as published by the Free Software Foundation; either version 2
8    * of the License, or (at your option) any later version.
9    *
10   * This program is distributed in the hope that it will be useful,
11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   * GNU General Public License for more details.
14   *
15   * You should have received a copy of the GNU General Public License
16   * along with this program; if not, write to the Free Software
17   * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18   */
19  package davmail.exchange;
20  
21  import davmail.Settings;
22  import davmail.util.StringUtil;
23  import org.apache.log4j.Logger;
24  
25  import java.io.*;
26  import java.text.ParseException;
27  import java.text.SimpleDateFormat;
28  import java.util.*;
29  
30  /**
31   * VCalendar object.
32   */
33  public class VCalendar extends VObject {
34      protected static final Logger LOGGER = Logger.getLogger(VCalendar.class);
35      protected VObject firstVevent;
36      protected VObject vTimezone;
37      protected String email;
38  
39      /**
40       * Create VCalendar object from reader;
41       *
42       * @param reader    stream reader
43       * @param email     current user email
44       * @param vTimezone user OWA timezone
45       * @throws IOException on error
46       */
47      public VCalendar(BufferedReader reader, String email, VObject vTimezone) throws IOException {
48          super(reader);
49          if (!"VCALENDAR".equals(type)) {
50              throw new IOException("Invalid type: " + type);
51          }
52          this.email = email;
53          // set OWA timezone information
54          if (this.vTimezone == null && vTimezone != null) {
55              setTimezone(vTimezone);
56          }
57      }
58  
59      /**
60       * Create VCalendar object from string;
61       *
62       * @param vCalendarBody item body
63       * @param email         current user email
64       * @param vTimezone     user OWA timezone
65       * @throws IOException on error
66       */
67      public VCalendar(String vCalendarBody, String email, VObject vTimezone) throws IOException {
68          this(new ICSBufferedReader(new StringReader(vCalendarBody)), email, vTimezone);
69      }
70  
71      /**
72       * Create VCalendar object from string;
73       *
74       * @param vCalendarContent item content
75       * @param email            current user email
76       * @param vTimezone        user OWA timezone
77       * @throws IOException on error
78       */
79      public VCalendar(byte[] vCalendarContent, String email, VObject vTimezone) throws IOException {
80          this(new ICSBufferedReader(new InputStreamReader(new ByteArrayInputStream(vCalendarContent), "UTF-8")), email, vTimezone);
81      }
82  
83      /**
84       * Empty constructor
85       */
86      public VCalendar() {
87          type = "VCALENDAR";
88      }
89  
90      /**
91       * Set timezone on vObject
92       *
93       * @param vTimezone timezone object
94       */
95      public void setTimezone(VObject vTimezone) {
96          if (vObjects == null) {
97              addVObject(vTimezone);
98          } else {
99              vObjects.add(0, vTimezone);
100         }
101         this.vTimezone = vTimezone;
102     }
103 
104     @Override
105     public void addVObject(VObject vObject) {
106         super.addVObject(vObject);
107         if (firstVevent == null && ("VEVENT".equals(vObject.type) || "VTODO".equals(vObject.type))) {
108             firstVevent = vObject;
109         }
110         if ("VTIMEZONE".equals(vObject.type)) {
111             vTimezone = vObject;
112         }
113     }
114 
115     protected boolean isAllDay(VObject vObject) {
116         VProperty dtstart = vObject.getProperty("DTSTART");
117         return dtstart != null && dtstart.hasParam("VALUE", "DATE");
118     }
119 
120     protected boolean isCdoAllDay(VObject vObject) {
121         return "TRUE".equals(vObject.getPropertyValue("X-MICROSOFT-CDO-ALLDAYEVENT"));
122     }
123 
124     /**
125      * Check if vCalendar is CDO allday.
126      *
127      * @return true if vCalendar has X-MICROSOFT-CDO-ALLDAYEVENT property set to TRUE
128      */
129     public boolean isCdoAllDay() {
130         return firstVevent != null && isCdoAllDay(firstVevent);
131     }
132 
133     /**
134      * Get email from property value.
135      *
136      * @param property property
137      * @return email value
138      */
139     public String getEmailValue(VProperty property) {
140         if (property == null) {
141             return null;
142         }
143         String propertyValue = property.getValue();
144         if (propertyValue != null && (propertyValue.startsWith("MAILTO:") || propertyValue.startsWith("mailto:"))) {
145             return propertyValue.substring(7);
146         } else {
147             return propertyValue;
148         }
149     }
150 
151     protected String getMethod() {
152         return getPropertyValue("METHOD");
153     }
154 
155     protected void fixVCalendar(boolean fromServer) {
156         // set iCal 4 global X-CALENDARSERVER-ACCESS from CLASS
157         if (fromServer) {
158             setPropertyValue("X-CALENDARSERVER-ACCESS", getCalendarServerAccess());
159         }
160 
161         if (fromServer && "PUBLISH".equals(getPropertyValue("METHOD"))) {
162             removeProperty("METHOD");
163         }
164 
165         // iCal 4 global X-CALENDARSERVER-ACCESS
166         String calendarServerAccess = getPropertyValue("X-CALENDARSERVER-ACCESS");
167         String now = ExchangeSession.getZuluDateFormat().format(new Date());
168 
169         // fix method from iPhone
170         if (!fromServer && getPropertyValue("METHOD") == null) {
171             setPropertyValue("METHOD", "PUBLISH");
172         }
173 
174         // rename TZID for maximum iCal/iPhone compatibility
175         String tzid = null;
176         if (fromServer) {
177             // get current tzid
178             VObject vObject = vTimezone;
179             if (vObject != null) {
180                 String currentTzid = vObject.getPropertyValue("TZID");
181                 // fix TZID with \n (Exchange 2010 bug)
182                 if (currentTzid != null && currentTzid.endsWith("\n")) {
183                     currentTzid = currentTzid.substring(0, currentTzid.length() - 1);
184                     vObject.setPropertyValue("TZID", currentTzid);
185                 }
186                 if (currentTzid != null && currentTzid.indexOf(' ') >= 0) {
187                     try {
188                         tzid = ResourceBundle.getBundle("timezones").getString(currentTzid);
189                         vObject.setPropertyValue("TZID", tzid);
190                     } catch (MissingResourceException e) {
191                         LOGGER.debug("Timezone " + currentTzid + " not found in rename table");
192                     }
193                 }
194             }
195         }
196 
197         if (!fromServer) {
198             fixTimezone();
199         }
200 
201         // iterate over vObjects
202         for (VObject vObject : vObjects) {
203             if ("VEVENT".equals(vObject.type)) {
204                 if (calendarServerAccess != null) {
205                     vObject.setPropertyValue("CLASS", getEventClass(calendarServerAccess));
206                     // iCal 3, get X-CALENDARSERVER-ACCESS from local VEVENT
207                 } else if (vObject.getPropertyValue("X-CALENDARSERVER-ACCESS") != null) {
208                     vObject.setPropertyValue("CLASS", getEventClass(vObject.getPropertyValue("X-CALENDARSERVER-ACCESS")));
209                 }
210                 if (fromServer) {
211                     // remove organizer line for event without attendees for iPhone
212                     if (vObject.getProperty("ATTENDEE") == null) {
213                         vObject.setPropertyValue("ORGANIZER", null);
214                     }
215                     // detect allday and update date properties
216                     if (isCdoAllDay(vObject)) {
217                         setClientAllday(vObject.getProperty("DTSTART"));
218                         setClientAllday(vObject.getProperty("DTEND"));
219                         setClientAllday(vObject.getProperty("RECURRENCE-ID"));
220                     }
221                     String cdoBusyStatus = vObject.getPropertyValue("X-MICROSOFT-CDO-BUSYSTATUS");
222                     if (cdoBusyStatus != null) {
223                         vObject.setPropertyValue("TRANSP",
224                                 !"FREE".equals(cdoBusyStatus) ? "OPAQUE" : "TRANSPARENT");
225                     }
226 
227                     // Apple iCal doesn't understand this key, and it's entourage
228                     // specific (i.e. not needed by any caldav client): strip it out
229                     vObject.removeProperty("X-ENTOURAGE_UUID");
230 
231                     splitExDate(vObject);
232 
233                     // remove empty properties
234                     if ("".equals(vObject.getPropertyValue("LOCATION"))) {
235                         vObject.removeProperty("LOCATION");
236                     }
237                     if ("".equals(vObject.getPropertyValue("DESCRIPTION"))) {
238                         vObject.removeProperty("DESCRIPTION");
239                     }
240                     if ("".equals(vObject.getPropertyValue("CLASS"))) {
241                         vObject.removeProperty("CLASS");
242                     }
243                     // rename TZID
244                     if (tzid != null) {
245                         VProperty dtStart = vObject.getProperty("DTSTART");
246                         if (dtStart != null && dtStart.getParam("TZID") != null) {
247                             dtStart.setParam("TZID", tzid);
248                         }
249                         VProperty dtEnd = vObject.getProperty("DTEND");
250                         if (dtEnd != null && dtEnd.getParam("TZID") != null) {
251                             dtEnd.setParam("TZID", tzid);
252                         }
253                         VProperty reccurrenceId = vObject.getProperty("RECURRENCE-ID");
254                         if (reccurrenceId != null && reccurrenceId.getParam("TZID") != null) {
255                             reccurrenceId.setParam("TZID", tzid);
256                         }
257                     }
258                     // remove unsupported attachment reference
259                     if (vObject.getProperty("ATTACH") != null) {
260                         List<String> toRemoveValues = null;
261                         List<String> values = vObject.getProperty("ATTACH").getValues();
262                         for (String value : values) {
263                             if (value.contains("CID:")) {
264                                 if (toRemoveValues == null) {
265                                     toRemoveValues = new ArrayList<String>();
266                                 }
267                                 toRemoveValues.add(value);
268                             }
269                         }
270                         if (toRemoveValues != null) {
271                             values.removeAll(toRemoveValues);
272                             if (values.size() == 0) {
273                                 vObject.removeProperty("ATTACH");
274                             }
275                         }
276                     }
277                 } else {
278                     // add organizer line to all events created in Exchange for active sync
279                     String organizer = getEmailValue(vObject.getProperty("ORGANIZER"));
280                     if (organizer == null) {
281                         vObject.setPropertyValue("ORGANIZER", "MAILTO:" + email);
282                     } else if (!email.equalsIgnoreCase(organizer) && vObject.getProperty("X-MICROSOFT-CDO-REPLYTIME") == null) {
283                         vObject.setPropertyValue("X-MICROSOFT-CDO-REPLYTIME", now);
284                     }
285                     // set OWA allday flag
286                     vObject.setPropertyValue("X-MICROSOFT-CDO-ALLDAYEVENT", isAllDay(vObject) ? "TRUE" : "FALSE");
287                     if (vObject.getPropertyValue("TRANSP") != null) {
288                         vObject.setPropertyValue("X-MICROSOFT-CDO-BUSYSTATUS",
289                                 !"TRANSPARENT".equals(vObject.getPropertyValue("TRANSP")) ? "BUSY" : "FREE");
290                     }
291 
292                     if (isAllDay(vObject)) {
293                         // convert date values to outlook compatible values
294                         setServerAllday(vObject.getProperty("DTSTART"));
295                         setServerAllday(vObject.getProperty("DTEND"));
296                     } else {
297                         fixTzid(vObject.getProperty("DTSTART"));
298                         fixTzid(vObject.getProperty("DTEND"));
299                     }
300                 }
301 
302                 fixAttendees(vObject, fromServer);
303 
304                 fixAlarm(vObject, fromServer);
305             }
306         }
307 
308     }
309 
310     private void fixTimezone() {
311         if (vTimezone != null && vTimezone.vObjects != null && vTimezone.vObjects.size() > 2) {
312             VObject standard = null;
313             VObject daylight = null;
314             for (VObject vObject : vTimezone.vObjects) {
315                 if ("STANDARD".equals(vObject.type)) {
316                     if (standard == null ||
317                             (vObject.getPropertyValue("DTSTART").compareTo(standard.getPropertyValue("DTSTART")) > 0)) {
318                         standard = vObject;
319                     }
320                 }
321                 if ("DAYLIGHT".equals(vObject.type)) {
322                     if (daylight == null ||
323                             (vObject.getPropertyValue("DTSTART").compareTo(daylight.getPropertyValue("DTSTART")) > 0)) {
324                         daylight = vObject;
325                     }
326                 }
327             }
328             vTimezone.vObjects.clear();
329             vTimezone.vObjects.add(standard);
330             vTimezone.vObjects.add(daylight);
331         }
332         // fix 3569922: quick workaround for broken Israeli Timezone issue
333         if (vTimezone != null && vTimezone.vObjects != null) {
334             for (VObject vObject:vTimezone.vObjects) {
335                 VProperty rrule = vObject.getProperty("RRULE");
336                 if (rrule != null && rrule.getValues().size() == 3 && "BYDAY=-2SU".equals(rrule.getValues().get(1))) {
337                     rrule.getValues().set(1, "BYDAY=4SU");
338                 }
339                 // Fix 555 another broken Israeli timezone
340                 if (rrule != null && rrule.getValues().size() == 4 && "BYDAY=FR".equals(rrule.getValues().get(1))
341                         && "BYMONTHDAY=23,24,25,26,27,28,29".equals(rrule.getValues().get(2))) {
342                     rrule.getValues().set(1, "BYDAY=-1FR");
343                     rrule.getValues().remove(2);
344                 }
345             }
346         }
347     }
348 
349     private void fixTzid(VProperty property) {
350         if (property != null && !property.hasParam("TZID")) {
351             property.addParam("TZID", vTimezone.getPropertyValue("TZID"));
352         }
353     }
354 
355     protected void splitExDate(VObject vObject) {
356         List<VProperty> exDateProperties = vObject.getProperties("EXDATE");
357         if (exDateProperties != null) {
358             for (VProperty property : exDateProperties) {
359                 String value = property.getValue();
360                 if (value.indexOf(',') >= 0) {
361                     // split property
362                     vObject.removeProperty(property);
363                     for (String singleValue : value.split(",")) {
364                         VProperty singleProperty = new VProperty("EXDATE", singleValue);
365                         singleProperty.setParams(property.getParams());
366                         vObject.addProperty(singleProperty);
367                     }
368                 }
369             }
370         }
371     }
372 
373     protected void setServerAllday(VProperty property) {
374         if (vTimezone != null) {
375             // set TZID param
376             if (!property.hasParam("TZID")) {
377                 property.addParam("TZID", vTimezone.getPropertyValue("TZID"));
378             }
379             // remove VALUE
380             property.removeParam("VALUE");
381             String value = property.getValue();
382             if (value.length() != 8) {
383                 LOGGER.warn("Invalid date value in allday event: " + value);
384             }
385             property.setValue(property.getValue() + "T000000");
386         }
387     }
388 
389     protected void setClientAllday(VProperty property) {
390         if (property != null) {
391             // set VALUE=DATE param
392             if (!property.hasParam("VALUE")) {
393                 property.addParam("VALUE", "DATE");
394             }
395             // remove TZID
396             property.removeParam("TZID");
397             String value = property.getValue();
398             if (value.length() != 8) {
399                 // try to convert datetime value to date value
400                 try {
401                     Calendar calendar = Calendar.getInstance();
402                     SimpleDateFormat dateParser = new SimpleDateFormat("yyyyMMdd'T'HHmmss");
403                     calendar.setTime(dateParser.parse(value));
404                     calendar.add(Calendar.HOUR_OF_DAY, 12);
405                     SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyyMMdd");
406                     value = dateFormatter.format(calendar.getTime());
407                 } catch (ParseException e) {
408                     LOGGER.warn("Invalid date value in allday event: " + value);
409                 }
410             }
411             property.setValue(value);
412         }
413     }
414 
415     protected void fixAlarm(VObject vObject, boolean fromServer) {
416         if (vObject.vObjects != null) {
417             if (Settings.getBooleanProperty("davmail.caldavDisableReminders", false)) {
418                 ArrayList<VObject> vAlarms = null;
419                 for (VObject vAlarm : vObject.vObjects) {
420                     if ("VALARM".equals(vAlarm.type)) {
421                         if (vAlarms == null) {
422                             vAlarms = new ArrayList<VObject>();
423                         }
424                         vAlarms.add(vAlarm);
425                     }
426                 }
427                 // remove all vAlarms
428                 if (vAlarms != null) {
429                     for (VObject vAlarm : vAlarms) {
430                         vObject.vObjects.remove(vAlarm);
431                     }
432                 }
433 
434             } else {
435                 for (VObject vAlarm : vObject.vObjects) {
436                     if ("VALARM".equals(vAlarm.type)) {
437                         String action = vAlarm.getPropertyValue("ACTION");
438                         if (fromServer && "DISPLAY".equals(action)
439                                 // convert DISPLAY to AUDIO only if user defined an alarm sound
440                                 && Settings.getProperty("davmail.caldavAlarmSound") != null) {
441                             // Convert alarm to audio for iCal
442                             vAlarm.setPropertyValue("ACTION", "AUDIO");
443 
444                             if (vAlarm.getPropertyValue("ATTACH") == null) {
445                                 // Add defined sound into the audio alarm
446                                 VProperty vProperty = new VProperty("ATTACH", Settings.getProperty("davmail.caldavAlarmSound"));
447                                 vProperty.addParam("VALUE", "URI");
448                                 vAlarm.addProperty(vProperty);
449                             }
450 
451                         } else if (!fromServer && "AUDIO".equals(action)) {
452                             // Use the alarm action that exchange (and blackberry) understand
453                             // (exchange and blackberry don't understand audio actions)
454                             vAlarm.setPropertyValue("ACTION", "DISPLAY");
455                         }
456                     }
457                 }
458             }
459         }
460     }
461 
462     /**
463      * Replace iCal4 (Snow Leopard) principal paths with mailto expression
464      *
465      * @param value attendee value or ics line
466      * @return fixed value
467      */
468     protected String replaceIcal4Principal(String value) {
469         if (value.contains("/principals/__uuids__/")) {
470             return value.replaceAll("/principals/__uuids__/([^/]*)__AT__([^/]*)/", "mailto:$1@$2");
471         } else {
472             return value;
473         }
474     }
475 
476     private void fixAttendees(VObject vObject, boolean fromServer) {
477         if (vObject.properties != null) {
478             for (VProperty property : vObject.properties) {
479                 if ("ATTENDEE".equalsIgnoreCase(property.getKey())) {
480                     if (fromServer) {
481                         // If this is coming from the server, strip out RSVP for this
482                         // user as an attendee where the partstat is something other
483                         // than PARTSTAT=NEEDS-ACTION since the RSVP confuses iCal4 into
484                         // thinking the attendee has not replied
485                         if (isCurrentUser(property) && property.hasParam("RSVP", "TRUE")) {
486                             if (!"NEEDS-ACTION".equals(property.getParamValue("PARTSTAT"))) {
487                                 property.removeParam("RSVP");
488                             }
489                         }
490                     } else {
491                         property.setValue(replaceIcal4Principal(property.getValue()));
492                     }
493                 }
494 
495             }
496         }
497 
498     }
499 
500     private boolean isCurrentUser(VProperty property) {
501         return property.getValue().equalsIgnoreCase("mailto:" + email);
502     }
503 
504     /**
505      * Return VTimezone object
506      *
507      * @return VTimezone
508      */
509     public VObject getVTimezone() {
510         return vTimezone;
511     }
512 
513     /**
514      * Convert X-CALENDARSERVER-ACCESS to CLASS.
515      * see http://svn.calendarserver.org/repository/calendarserver/CalendarServer/trunk/doc/Extensions/caldav-privateevents.txt
516      *
517      * @param calendarServerAccess X-CALENDARSERVER-ACCESS value
518      * @return CLASS value
519      */
520     protected String getEventClass(String calendarServerAccess) {
521         if ("PRIVATE".equalsIgnoreCase(calendarServerAccess)) {
522             return "CONFIDENTIAL";
523         } else if ("CONFIDENTIAL".equalsIgnoreCase(calendarServerAccess) || "RESTRICTED".equalsIgnoreCase(calendarServerAccess)) {
524             return "PRIVATE";
525         } else {
526             return null;
527         }
528     }
529 
530     /**
531      * Convert CLASS to X-CALENDARSERVER-ACCESS.
532      * see http://svn.calendarserver.org/repository/calendarserver/CalendarServer/trunk/doc/Extensions/caldav-privateevents.txt     *
533      *
534      * @return X-CALENDARSERVER-ACCESS value
535      */
536     protected String getCalendarServerAccess() {
537         String eventClass = getFirstVeventPropertyValue("CLASS");
538         if ("PRIVATE".equalsIgnoreCase(eventClass)) {
539             return "CONFIDENTIAL";
540         } else if ("CONFIDENTIAL".equalsIgnoreCase(eventClass)) {
541             return "PRIVATE";
542         } else {
543             return null;
544         }
545     }
546 
547     /**
548      * Get property value from first VEVENT in VCALENDAR.
549      *
550      * @param name property name
551      * @return property value
552      */
553     public String getFirstVeventPropertyValue(String name) {
554         if (firstVevent == null) {
555             return null;
556         } else {
557             return firstVevent.getPropertyValue(name);
558         }
559     }
560 
561     protected VProperty getFirstVeventProperty(String name) {
562         if (firstVevent == null) {
563             return null;
564         } else {
565             return firstVevent.getProperty(name);
566         }
567     }
568 
569 
570     /**
571      * Get properties by name from first VEVENT.
572      *
573      * @param name property name
574      * @return properties
575      */
576     public List<VProperty> getFirstVeventProperties(String name) {
577         if (firstVevent == null) {
578             return null;
579         } else {
580             return firstVevent.getProperties(name);
581         }
582     }
583 
584     /**
585      * Remove VAlarm from VCalendar.
586      */
587     public void removeVAlarm() {
588         if (vObjects != null) {
589             for (VObject vObject : vObjects) {
590                 if ("VEVENT".equals(vObject.type)) {
591                     // As VALARM is the only possible inner object, just drop all objects
592                     if (vObject.vObjects != null) {
593                         vObject.vObjects = null;
594                     }
595                 }
596             }
597         }
598     }
599 
600     /**
601      * Check if VCalendar has a VALARM item.
602      *
603      * @return true if VCalendar has a VALARM
604      */
605     public boolean hasVAlarm() {
606         if (vObjects != null) {
607             for (VObject vObject : vObjects) {
608                 if ("VEVENT".equals(vObject.type)) {
609                     if (vObject.vObjects != null) {
610                         return true;
611                     }
612                 }
613             }
614         }
615         return false;
616     }
617 
618     /**
619      * Check if this VCalendar is a meeting.
620      *
621      * @return true if this VCalendar has attendees
622      */
623     public boolean isMeeting() {
624         return getFirstVeventProperty("ATTENDEE") != null;
625     }
626 
627     /**
628      * Check if current user is meeting organizer.
629      *
630      * @return true it user email matched organizer email
631      */
632     public boolean isMeetingOrganizer() {
633         return email.equalsIgnoreCase(getEmailValue(getFirstVeventProperty("ORGANIZER")));
634     }
635 
636     /**
637      * Set property value on first VEVENT.
638      *
639      * @param propertyName  property name
640      * @param propertyValue property value
641      */
642     public void setFirstVeventPropertyValue(String propertyName, String propertyValue) {
643         firstVevent.setPropertyValue(propertyName, propertyValue);
644     }
645 
646     /**
647      * Add property on first VEVENT.
648      *
649      * @param vProperty property object
650      */
651     public void addFirstVeventProperty(VProperty vProperty) {
652         firstVevent.addProperty(vProperty);
653     }
654 
655     /**
656      * Check if this item is a VTODO item
657      *
658      * @return true with VTODO items
659      */
660     public boolean isTodo() {
661         return "VTODO".equals(firstVevent.type);
662     }
663 
664     /**
665      * VCalendar recipients for notifications
666      */
667     public static class Recipients {
668         /**
669          * attendee list
670          */
671         public String attendees;
672 
673         /**
674          * optional attendee list
675          */
676         public String optionalAttendees;
677 
678         /**
679          * vCalendar organizer
680          */
681         public String organizer;
682     }
683 
684     /**
685      * Build recipients value for VCalendar.
686      *
687      * @param isNotification if true, filter recipients that should receive meeting notifications
688      * @return notification/event recipients
689      */
690     public Recipients getRecipients(boolean isNotification) {
691 
692         HashSet<String> attendees = new HashSet<String>();
693         HashSet<String> optionalAttendees = new HashSet<String>();
694 
695         // get recipients from first VEVENT
696         List<VProperty> attendeeProperties = getFirstVeventProperties("ATTENDEE");
697         if (attendeeProperties != null) {
698             for (VProperty property : attendeeProperties) {
699                 // exclude current user and invalid values from recipients
700                 // also exclude no action attendees
701                 String attendeeEmail = getEmailValue(property);
702                 if (!email.equalsIgnoreCase(attendeeEmail) && attendeeEmail != null && attendeeEmail.indexOf('@') >= 0
703                         // return all attendees for user calendar folder, filter for notifications
704                         && (!isNotification
705                         // notify attendee if reply explicitly requested
706                         || (property.hasParam("RSVP", "TRUE"))
707                         || (
708                         // workaround for iCal bug: do not notify if reply explicitly not requested
709                         !(property.hasParam("RSVP", "FALSE")) &&
710                                 ((property.hasParam("PARTSTAT", "NEEDS-ACTION")
711                                         // need to include other PARTSTATs participants for CANCEL notifications
712                                         || property.hasParam("PARTSTAT", "ACCEPTED")
713                                         || property.hasParam("PARTSTAT", "DECLINED")
714                                         || property.hasParam("PARTSTAT", "TENTATIVE")))
715                 ))) {
716                     if (property.hasParam("ROLE", "OPT-PARTICIPANT")) {
717                         optionalAttendees.add(attendeeEmail);
718                     } else {
719                         attendees.add(attendeeEmail);
720                     }
721                 }
722             }
723         }
724         Recipients recipients = new Recipients();
725         recipients.organizer = getEmailValue(getFirstVeventProperty("ORGANIZER"));
726         recipients.attendees = StringUtil.join(attendees, ", ");
727         recipients.optionalAttendees = StringUtil.join(optionalAttendees, ", ");
728         return recipients;
729     }
730 
731     protected String getAttendeeStatus() {
732         String status = null;
733         List<VProperty> attendeeProperties = getFirstVeventProperties("ATTENDEE");
734         if (attendeeProperties != null) {
735             for (VProperty property : attendeeProperties) {
736                 String attendeeEmail = getEmailValue(property);
737                 if (email.equalsIgnoreCase(attendeeEmail) && property.hasParam("PARTSTAT")) {
738                     // found current user attendee line
739                     status = property.getParamValue("PARTSTAT");
740                     break;
741                 }
742             }
743         }
744         return status;
745     }
746 
747     /**
748      * Get first VEvent
749      * @return first VEvent
750      */
751     public VObject getFirstVevent() {
752         return firstVevent;
753     }
754 
755     /**
756      * Get recurring VCalendar occurence exceptions.
757      *
758      * @return event occurences
759      */
760     public List<VObject> getModifiedOccurrences() {
761         boolean first = true;
762         ArrayList<VObject> results = new ArrayList<VObject>();
763         for (VObject vObject : vObjects) {
764             if ("VEVENT".equals(vObject.type)) {
765                 if (first) {
766                     first = false;
767                 } else {
768                     results.add(vObject);
769                 }
770             }
771         }
772         return results;
773     }
774 }