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.nio.charset.StandardCharsets;
27  import java.text.ParseException;
28  import java.text.SimpleDateFormat;
29  import java.util.*;
30  
31  /**
32   * VCalendar object.
33   */
34  public class VCalendar extends VObject {
35      protected static final Logger LOGGER = Logger.getLogger(VCalendar.class);
36      protected VObject firstVevent;
37      protected VObject vTimezone;
38      protected String email;
39  
40      /**
41       * Create VCalendar object from reader;
42       *
43       * @param reader    stream reader
44       * @param email     current user email
45       * @param vTimezone user OWA timezone
46       * @throws IOException on error
47       */
48      public VCalendar(BufferedReader reader, String email, VObject vTimezone) throws IOException {
49          super(reader);
50          if (!"VCALENDAR".equals(type)) {
51              throw new IOException("Invalid type: " + type);
52          }
53          this.email = email;
54          // set OWA timezone information
55          if (this.vTimezone == null && vTimezone != null) {
56              setTimezone(vTimezone);
57          }
58      }
59  
60      /**
61       * Create VCalendar object from string;
62       *
63       * @param vCalendarBody item body
64       * @param email         current user email
65       * @param vTimezone     user OWA timezone
66       * @throws IOException on error
67       */
68      public VCalendar(String vCalendarBody, String email, VObject vTimezone) throws IOException {
69          this(new ICSBufferedReader(new StringReader(vCalendarBody)), email, vTimezone);
70      }
71  
72      /**
73       * Create VCalendar object from string;
74       *
75       * @param vCalendarContent item content
76       * @param email            current user email
77       * @param vTimezone        user OWA timezone
78       * @throws IOException on error
79       */
80      public VCalendar(byte[] vCalendarContent, String email, VObject vTimezone) throws IOException {
81          this(new ICSBufferedReader(new InputStreamReader(new ByteArrayInputStream(vCalendarContent), StandardCharsets.UTF_8)), email, vTimezone);
82      }
83  
84      /**
85       * Empty constructor
86       */
87      public VCalendar() {
88          type = "VCALENDAR";
89      }
90  
91      /**
92       * Set timezone on vObject
93       *
94       * @param vTimezone timezone object
95       */
96      public void setTimezone(VObject vTimezone) {
97          if (vObjects == null) {
98              addVObject(vTimezone);
99          } else {
100             vObjects.add(0, vTimezone);
101         }
102         this.vTimezone = vTimezone;
103     }
104 
105     @Override
106     public void addVObject(VObject vObject) {
107         if (firstVevent == null && ("VEVENT".equals(vObject.type) || "VTODO".equals(vObject.type))) {
108             firstVevent = vObject;
109         }
110         if ("VTIMEZONE".equals(vObject.type)) {
111             if (vTimezone == null) {
112                 vTimezone = vObject;
113             } else if (vTimezone.getPropertyValue("TZID").equals(vObject.getPropertyValue("TZID"))){
114                 // drop duplicate TZID definition (Korganizer bug)
115                 vObject = null;
116             }
117         }
118         if (vObject != null) {
119             super.addVObject(vObject);
120         }
121     }
122 
123     protected boolean isAllDay(VObject vObject) {
124         VProperty dtstart = vObject.getProperty("DTSTART");
125         return dtstart != null && dtstart.hasParam("VALUE", "DATE");
126     }
127 
128     protected boolean isCdoAllDay(VObject vObject) {
129         return "TRUE".equals(vObject.getPropertyValue("X-MICROSOFT-CDO-ALLDAYEVENT"));
130     }
131 
132     /**
133      * Check if vCalendar is CDO allday.
134      *
135      * @return true if vCalendar has X-MICROSOFT-CDO-ALLDAYEVENT property set to TRUE
136      */
137     public boolean isCdoAllDay() {
138         return firstVevent != null && isCdoAllDay(firstVevent);
139     }
140 
141     /**
142      * Get email from property value.
143      *
144      * @param property property
145      * @return email value
146      */
147     public String getEmailValue(VProperty property) {
148         if (property == null) {
149             return null;
150         }
151         String propertyValue = property.getValue();
152         if (propertyValue != null && (propertyValue.startsWith("MAILTO:") || propertyValue.startsWith("mailto:"))) {
153             return propertyValue.substring(7);
154         } else {
155             return propertyValue;
156         }
157     }
158 
159     protected String getMethod() {
160         return getPropertyValue("METHOD");
161     }
162 
163     protected void fixVCalendar(boolean fromServer) {
164         // set iCal 4 global X-CALENDARSERVER-ACCESS from CLASS
165         if (fromServer) {
166             setPropertyValue("X-CALENDARSERVER-ACCESS", getCalendarServerAccess());
167         }
168 
169         if (fromServer && "PUBLISH".equals(getPropertyValue("METHOD"))) {
170             removeProperty("METHOD");
171         }
172 
173         // iCal 4 global X-CALENDARSERVER-ACCESS
174         String calendarServerAccess = getPropertyValue("X-CALENDARSERVER-ACCESS");
175         String now = ExchangeSession.getZuluDateFormat().format(new Date());
176 
177         // fix method from iPhone
178         if (!fromServer && getPropertyValue("METHOD") == null) {
179             setPropertyValue("METHOD", "PUBLISH");
180         }
181 
182         // rename TZID for maximum iCal/iPhone compatibility
183         if (fromServer) {
184             // get current tzid
185             VObject vObject = vTimezone;
186             if (vObject != null) {
187                 String currentTzid = vObject.getPropertyValue("TZID");
188                 vObject.setPropertyValue("TZID", fixupTZID(currentTzid));
189             }
190         }
191 
192         if (!fromServer) {
193             fixTimezoneToServer();
194         }
195 
196         // iterate over vObjects
197         for (VObject vObject : vObjects) {
198             if (vObject.isVEvent()) {
199                 if (calendarServerAccess != null) {
200                     vObject.setPropertyValue("CLASS", getEventClass(calendarServerAccess));
201                     // iCal 3, get X-CALENDARSERVER-ACCESS from local VEVENT
202                 } else if (vObject.getPropertyValue("X-CALENDARSERVER-ACCESS") != null) {
203                     vObject.setPropertyValue("CLASS", getEventClass(vObject.getPropertyValue("X-CALENDARSERVER-ACCESS")));
204                 }
205                 if (fromServer) {
206                     // remove organizer line for event without attendees for iPhone
207                     if (vObject.getProperty("ATTENDEE") == null) {
208                         vObject.setPropertyValue("ORGANIZER", null);
209                     }
210                     // detect allday and update date properties
211                     if (isCdoAllDay(vObject)) {
212                         setClientAllday(vObject.getProperty("DTSTART"));
213                         setClientAllday(vObject.getProperty("DTEND"));
214                         setClientAllday(vObject.getProperty("RECURRENCE-ID"));
215                     }
216                     String cdoBusyStatus = vObject.getPropertyValue("X-MICROSOFT-CDO-BUSYSTATUS");
217                     if (cdoBusyStatus != null) {
218                         // we set status only if it's tentative
219                         if ("TENTATIVE".equals(cdoBusyStatus)) {
220                             vObject.setPropertyValue("STATUS", "TENTATIVE");
221                         }
222                         // in all cases, we set the transparency (also called "show time as" in UI)
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 TZIDs
244                     VProperty dtStart = vObject.getProperty("DTSTART");
245                     if (dtStart != null && dtStart.getParam("TZID") != null) {
246                         dtStart.setParam("TZID", fixupTZID(dtStart.getParamValue("TZID")));
247                     }
248                     VProperty dtEnd = vObject.getProperty("DTEND");
249                     if (dtEnd != null && dtEnd.getParam("TZID") != null) {
250                         dtEnd.setParam("TZID", fixupTZID(dtEnd.getParamValue("TZID")));
251                     }
252                     VProperty recurrenceId = vObject.getProperty("RECURRENCE-ID");
253                     if (recurrenceId != null && recurrenceId.getParam("TZID") != null) {
254                         recurrenceId.setParam("TZID", fixupTZID(recurrenceId.getParamValue("TZID")));
255                     }
256                     VProperty exDate = vObject.getProperty("EXDATE");
257                     if (exDate != null && exDate.getParam("TZID") != null) {
258                         exDate.setParam("TZID", fixupTZID(exDate.getParamValue("TZID")));
259                     }
260                     // remove unsupported attachment reference
261                     if (vObject.getProperty("ATTACH") != null) {
262                         List<String> toRemoveValues = null;
263                         List<String> values = vObject.getProperty("ATTACH").getValues();
264                         for (String value : values) {
265                             if (value.contains("CID:")) {
266                                 if (toRemoveValues == null) {
267                                     toRemoveValues = new ArrayList<>();
268                                 }
269                                 toRemoveValues.add(value);
270                             }
271                         }
272                         if (toRemoveValues != null) {
273                             values.removeAll(toRemoveValues);
274                             if (values.isEmpty()) {
275                                 vObject.removeProperty("ATTACH");
276                             }
277                         }
278                     }
279                 } else {
280                     // add organizer line to all events created in Exchange for active sync
281                     String organizer = getEmailValue(vObject.getProperty("ORGANIZER"));
282                     if (organizer == null) {
283                         vObject.setPropertyValue("ORGANIZER", "MAILTO:" + email);
284                     } else if (!email.equalsIgnoreCase(organizer) && vObject.getProperty("X-MICROSOFT-CDO-REPLYTIME") == null) {
285                         vObject.setPropertyValue("X-MICROSOFT-CDO-REPLYTIME", now);
286                     }
287                     // set OWA allday flag
288                     vObject.setPropertyValue("X-MICROSOFT-CDO-ALLDAYEVENT", isAllDay(vObject) ? "TRUE" : "FALSE");
289                     if (vObject.getPropertyValue("TRANSP") != null) {
290                         vObject.setPropertyValue("X-MICROSOFT-CDO-BUSYSTATUS",
291                                 !"TRANSPARENT".equals(vObject.getPropertyValue("TRANSP")) ? "BUSY" : "FREE");
292                     }
293 
294                     if (isAllDay(vObject)) {
295                         // convert date values to outlook compatible values
296                         setServerAllday(vObject.getProperty("DTSTART"));
297                         setServerAllday(vObject.getProperty("DTEND"));
298                     } else {
299                         fixTzid(vObject.getProperty("DTSTART"));
300                         fixTzid(vObject.getProperty("DTEND"));
301                     }
302                 }
303 
304                 fixAttendees(vObject, fromServer);
305 
306                 fixAlarm(vObject, fromServer);
307             }
308         }
309 
310     }
311 
312     private String fixupTZID(String currentTzid) {
313         // fix TZID with \n (Exchange 2010 bug)
314         if (currentTzid != null && currentTzid.endsWith("\n")) {
315             currentTzid = currentTzid.substring(0, currentTzid.length() - 1);
316         }
317         if (currentTzid != null && currentTzid.indexOf(' ') >= 0) {
318             try {
319                 currentTzid = ResourceBundle.getBundle("timezones").getString(currentTzid);
320             } catch (MissingResourceException e) {
321                 LOGGER.debug("Timezone " + currentTzid + " not found in rename table");
322             }
323         }
324         return currentTzid;
325     }
326 
327     private void fixTimezoneToServer() {
328         if (vTimezone != null && vTimezone.vObjects != null && vTimezone.vObjects.size() > 2) {
329             VObject standard = null;
330             VObject daylight = null;
331             for (VObject vObject : vTimezone.vObjects) {
332                 if ("STANDARD".equals(vObject.type)) {
333                     if (standard == null ||
334                             (vObject.getPropertyValue("DTSTART").compareTo(standard.getPropertyValue("DTSTART")) > 0)) {
335                         standard = vObject;
336                     }
337                 }
338                 if ("DAYLIGHT".equals(vObject.type)) {
339                     if (daylight == null ||
340                             (vObject.getPropertyValue("DTSTART").compareTo(daylight.getPropertyValue("DTSTART")) > 0)) {
341                         daylight = vObject;
342                     }
343                 }
344             }
345             vTimezone.vObjects.clear();
346             vTimezone.vObjects.add(standard);
347             vTimezone.vObjects.add(daylight);
348         }
349         // fix 3569922: quick workaround for broken Israeli Timezone issue
350         if (vTimezone != null && vTimezone.vObjects != null) {
351             for (VObject vObject : vTimezone.vObjects) {
352                 VProperty rrule = vObject.getProperty("RRULE");
353                 if (rrule != null && rrule.getValues().size() == 3 && "BYDAY=-2SU".equals(rrule.getValues().get(1))) {
354                     rrule.getValues().set(1, "BYDAY=4SU");
355                 }
356                 // Fix 555 another broken Israeli timezone
357                 if (rrule != null && rrule.getValues().size() == 4 && "BYDAY=FR".equals(rrule.getValues().get(1))
358                         && "BYMONTHDAY=23,24,25,26,27,28,29".equals(rrule.getValues().get(2))) {
359                     rrule.getValues().set(1, "BYDAY=-1FR");
360                     rrule.getValues().remove(2);
361                 }
362             }
363         }
364 
365         // validate RRULE - COUNT and UNTIL may not occur at once
366         if (vTimezone != null && vTimezone.vObjects != null) {
367             for (VObject vObject : vTimezone.vObjects) {
368                 VProperty rrule = vObject.getProperty("RRULE");
369                 if (rrule != null) {
370                     Map<String, String> rruleValueMap = rrule.getValuesAsMap();
371                     if (rruleValueMap.containsKey("UNTIL") && rruleValueMap.containsKey("COUNT")) {
372                         rrule.removeValue("UNTIL="+rruleValueMap.get("UNTIL"));
373                     }
374                 }
375             }
376         }
377         // end validate RRULE
378 
379         // convert TZID to Exchange time zone id
380         ResourceBundle tzBundle = ResourceBundle.getBundle("exchtimezones");
381         ResourceBundle tzidsBundle = ResourceBundle.getBundle("stdtimezones");
382         for (VObject vObject : vObjects) {
383             if (vObject.isVTimezone()) {
384                 String tzid = vObject.getPropertyValue("TZID");
385                 // check if tzid is a valid Exchange timezone id
386                 if (!tzidsBundle.containsKey(tzid)) {
387                     String exchangeTzid = null;
388                     // try to convert standard timezone id to Exchange timezone id
389                     if (tzBundle.containsKey(tzid)) {
390                         exchangeTzid = tzBundle.getString(tzid);
391                     } else {
392                         // failover, map to a close timezone
393                         for (VObject tzDefinition : vObject.vObjects) {
394                             if ("STANDARD".equals(tzDefinition.type)) {
395                                 exchangeTzid = getTzidFromOffset(tzDefinition.getPropertyValue("TZOFFSETTO"));
396                             }
397                         }
398                     }
399                     if (exchangeTzid != null) {
400                         vObject.setPropertyValue("TZID", exchangeTzid);
401                         // also replace TZID in properties
402                         updateTzid(tzid, exchangeTzid);
403                     }
404                 }
405             }
406         }
407     }
408 
409     protected void updateTzid(String tzid, String newTzid) {
410         for (VObject vObject : vObjects) {
411             if (vObject.isVEvent() || vObject.isVTodo()) {
412                 for (VProperty vProperty : vObject.properties) {
413                     if (tzid.equalsIgnoreCase(vProperty.getParamValue("TZID"))) {
414                         vProperty.setParam("TZID", newTzid);
415                     }
416                 }
417             }
418         }
419     }
420 
421     private void fixTzid(VProperty property) {
422         if (property != null && !property.hasParam("TZID")) {
423             property.addParam("TZID", vTimezone.getPropertyValue("TZID"));
424         }
425     }
426 
427     protected void splitExDate(VObject vObject) {
428         List<VProperty> exDateProperties = vObject.getProperties("EXDATE");
429         if (exDateProperties != null) {
430             for (VProperty property : exDateProperties) {
431                 String value = property.getValue();
432                 if (value.indexOf(',') >= 0) {
433                     // split property
434                     vObject.removeProperty(property);
435                     for (String singleValue : value.split(",")) {
436                         VProperty singleProperty = new VProperty("EXDATE", singleValue);
437                         singleProperty.setParams(property.getParams());
438                         vObject.addProperty(singleProperty);
439                     }
440                 }
441             }
442         }
443     }
444 
445     protected void setServerAllday(VProperty property) {
446         if (vTimezone != null) {
447             // set TZID param
448             if (!property.hasParam("TZID")) {
449                 property.addParam("TZID", vTimezone.getPropertyValue("TZID"));
450             }
451             // remove VALUE
452             property.removeParam("VALUE");
453             String value = property.getValue();
454             if (value.length() != 8) {
455                 LOGGER.warn("Invalid date value in allday event: " + value);
456             }
457             property.setValue(property.getValue() + "T000000");
458         }
459     }
460 
461     protected void setClientAllday(VProperty property) {
462         if (property != null) {
463             // set VALUE=DATE param
464             if (!property.hasParam("VALUE")) {
465                 property.addParam("VALUE", "DATE");
466             }
467             // remove TZID
468             property.removeParam("TZID");
469             String value = property.getValue();
470             if (value.length() != 8) {
471                 // try to convert datetime value to date value
472                 try {
473                     Calendar calendar = Calendar.getInstance();
474                     SimpleDateFormat dateParser = new SimpleDateFormat("yyyyMMdd'T'HHmmss");
475                     calendar.setTime(dateParser.parse(value));
476                     calendar.add(Calendar.HOUR_OF_DAY, 12);
477                     SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyyMMdd");
478                     value = dateFormatter.format(calendar.getTime());
479                 } catch (ParseException e) {
480                     LOGGER.warn("Invalid date value in allday event: " + value);
481                 }
482             }
483             property.setValue(value);
484         }
485     }
486 
487     protected void fixAlarm(VObject vObject, boolean fromServer) {
488         if (vObject.vObjects != null) {
489             if (Settings.getBooleanProperty("davmail.caldavDisableReminders", false)) {
490                 ArrayList<VObject> vAlarms = null;
491                 for (VObject vAlarm : vObject.vObjects) {
492                     if ("VALARM".equals(vAlarm.type)) {
493                         if (vAlarms == null) {
494                             vAlarms = new ArrayList<>();
495                         }
496                         vAlarms.add(vAlarm);
497                     }
498                 }
499                 // remove all vAlarms
500                 if (vAlarms != null) {
501                     for (VObject vAlarm : vAlarms) {
502                         vObject.vObjects.remove(vAlarm);
503                     }
504                 }
505 
506             } else {
507                 for (VObject vAlarm : vObject.vObjects) {
508                     if ("VALARM".equals(vAlarm.type)) {
509                         String action = vAlarm.getPropertyValue("ACTION");
510                         if (fromServer && "DISPLAY".equals(action)
511                                 // convert DISPLAY to AUDIO only if user defined an alarm sound
512                                 && Settings.getProperty("davmail.caldavAlarmSound") != null) {
513                             // Convert alarm to audio for iCal
514                             vAlarm.setPropertyValue("ACTION", "AUDIO");
515 
516                             if (vAlarm.getPropertyValue("ATTACH") == null) {
517                                 // Add defined sound into the audio alarm
518                                 VProperty vProperty = new VProperty("ATTACH", Settings.getProperty("davmail.caldavAlarmSound"));
519                                 vProperty.addParam("VALUE", "URI");
520                                 vAlarm.addProperty(vProperty);
521                             }
522 
523                         } else if (!fromServer && "AUDIO".equals(action)) {
524                             // Use the alarm action that exchange (and blackberry) understand
525                             // (exchange and blackberry don't understand audio actions)
526                             vAlarm.setPropertyValue("ACTION", "DISPLAY");
527                         }
528                     }
529                 }
530             }
531         }
532     }
533 
534     /**
535      * Replace iCal4 (Snow Leopard) principal paths with mailto expression
536      *
537      * @param value attendee value or ics line
538      * @return fixed value
539      */
540     protected String replaceIcal4Principal(String value) {
541         final String principalPrefix = "/principals/__uuids__/";
542         final String principalAt = "__AT__";
543         if (value.contains(principalPrefix) && value.contains(principalAt)) {
544             return "mailto:" +
545                     value.substring(value.indexOf(principalPrefix) + principalPrefix.length(), value.indexOf(principalAt)) +
546                     "@" +
547                     value.substring(value.indexOf(principalAt) + principalAt.length(), value.length() - 1);
548         } else {
549             return value;
550         }
551     }
552 
553     private void fixAttendees(VObject vObject, boolean fromServer) {
554         if (vObject.properties != null) {
555             for (VProperty property : vObject.properties) {
556                 if ("ATTENDEE".equalsIgnoreCase(property.getKey())) {
557                     if (fromServer) {
558                         // If this is coming from the server, strip out RSVP for this
559                         // user as an attendee where the partstat is something other
560                         // than PARTSTAT=NEEDS-ACTION since the RSVP confuses iCal4 into
561                         // thinking the attendee has not replied
562                         if (isCurrentUser(property) && property.hasParam("RSVP", "TRUE")) {
563                             if (!"NEEDS-ACTION".equals(property.getParamValue("PARTSTAT"))) {
564                                 property.removeParam("RSVP");
565                             }
566                         }
567                     } else {
568                         property.setValue(replaceIcal4Principal(property.getValue()));
569                     }
570                 }
571 
572             }
573         }
574 
575     }
576 
577     private boolean isCurrentUser(VProperty property) {
578         return property.getValue().equalsIgnoreCase("mailto:" + email);
579     }
580 
581     /**
582      * Return VTimezone object
583      *
584      * @return VTimezone
585      */
586     public VObject getVTimezone() {
587         return vTimezone;
588     }
589 
590     /**
591      * Convert X-CALENDARSERVER-ACCESS to CLASS.
592      * see <a href="http://svn.calendarserver.org/repository/calendarserver/CalendarServer/trunk/doc/Extensions/caldav-privateevents.txt">caldav-privateevents.txt</a>
593      *
594      * @param calendarServerAccess X-CALENDARSERVER-ACCESS value
595      * @return CLASS value
596      */
597     protected String getEventClass(String calendarServerAccess) {
598         if ("PRIVATE".equalsIgnoreCase(calendarServerAccess)) {
599             return "CONFIDENTIAL";
600         } else if ("CONFIDENTIAL".equalsIgnoreCase(calendarServerAccess) || "RESTRICTED".equalsIgnoreCase(calendarServerAccess)) {
601             return "PRIVATE";
602         } else {
603             return null;
604         }
605     }
606 
607     /**
608      * Convert CLASS to X-CALENDARSERVER-ACCESS.
609      * see <a href="http://svn.calendarserver.org/repository/calendarserver/CalendarServer/trunk/doc/Extensions/caldav-privateevents.txt">caldav-privateevents.txt</a>
610      *
611      * @return X-CALENDARSERVER-ACCESS value
612      */
613     protected String getCalendarServerAccess() {
614         String eventClass = getFirstVeventPropertyValue("CLASS");
615         if ("PRIVATE".equalsIgnoreCase(eventClass)) {
616             return "CONFIDENTIAL";
617         } else if ("CONFIDENTIAL".equalsIgnoreCase(eventClass)) {
618             return "PRIVATE";
619         } else {
620             return null;
621         }
622     }
623 
624     /**
625      * Get property value from first VEVENT in VCALENDAR.
626      *
627      * @param name property name
628      * @return property value
629      */
630     public String getFirstVeventPropertyValue(String name) {
631         if (firstVevent == null) {
632             return null;
633         } else {
634             return firstVevent.getPropertyValue(name);
635         }
636     }
637 
638     protected VProperty getFirstVeventProperty(String name) {
639         if (firstVevent == null) {
640             return null;
641         } else {
642             return firstVevent.getProperty(name);
643         }
644     }
645 
646 
647     /**
648      * Get properties by name from the first VEVENT.
649      *
650      * @param name property name
651      * @return properties
652      */
653     public List<VProperty> getFirstVeventProperties(String name) {
654         if (firstVevent == null) {
655             return null;
656         } else {
657             return firstVevent.getProperties(name);
658         }
659     }
660 
661     /**
662      * Remove VAlarm from VCalendar.
663      */
664     public void removeVAlarm() {
665         if (vObjects != null) {
666             for (VObject vObject : vObjects) {
667                 if ("VEVENT".equals(vObject.type)) {
668                     // As VALARM is the only possible inner object, just drop all objects
669                     if (vObject.vObjects != null) {
670                         vObject.vObjects = null;
671                     }
672                 }
673             }
674         }
675     }
676 
677     /**
678      * Check if VCalendar has a VALARM item.
679      *
680      * @return true if VCalendar has a VALARM
681      */
682     public boolean hasVAlarm() {
683         if (vObjects != null) {
684             for (VObject vObject : vObjects) {
685                 if ("VEVENT".equals(vObject.type)) {
686                     if (vObject.vObjects != null && !vObject.vObjects.isEmpty()) {
687                         return vObject.vObjects.get(0).isVAlarm();
688                     }
689                 }
690             }
691         }
692         return false;
693     }
694 
695     public String getReminderMinutesBeforeStart() {
696         String result = "0";
697         if (vObjects != null) {
698             for (VObject vObject : vObjects) {
699                 if (vObject.vObjects != null && !vObject.vObjects.isEmpty() &&
700                         vObject.vObjects.get(0).isVAlarm()) {
701                     String trigger = vObject.vObjects.get(0).getPropertyValue("TRIGGER");
702                     if (trigger != null) {
703                         if (trigger.startsWith("-PT") && trigger.endsWith("M")) {
704                             result = trigger.substring(3, trigger.length() - 1);
705                         } else if (trigger.startsWith("-PT") && trigger.endsWith("H")) {
706                             result = trigger.substring(3, trigger.length() - 1);
707                             // convert to minutes
708                             result = String.valueOf(Integer.parseInt(result) * 60);
709                         } else if (trigger.startsWith("-P") && trigger.endsWith("D")) {
710                             result = trigger.substring(2, trigger.length() - 1);
711                             // convert to minutes
712                             result = String.valueOf(Integer.parseInt(result) * 60 * 24);
713                         } else if (trigger.startsWith("-P") && trigger.endsWith("W")) {
714                             result = trigger.substring(2, trigger.length() - 1);
715                             // convert to minutes
716                             result = String.valueOf(Integer.parseInt(result) * 60 * 24 * 7);
717                         }
718                     }
719                 }
720             }
721         }
722         return result;
723     }
724 
725 
726     /**
727      * Check if this VCalendar is a meeting.
728      *
729      * @return true if this VCalendar has attendees
730      */
731     public boolean isMeeting() {
732         return getFirstVeventProperty("ATTENDEE") != null;
733     }
734 
735     /**
736      * Check if current user is meeting organizer.
737      *
738      * @return true it user email matched organizer email
739      */
740     public boolean isMeetingOrganizer() {
741         return email.equalsIgnoreCase(getEmailValue(getFirstVeventProperty("ORGANIZER")));
742     }
743 
744     /**
745      * Set property value on first VEVENT.
746      *
747      * @param propertyName  property name
748      * @param propertyValue property value
749      */
750     public void setFirstVeventPropertyValue(String propertyName, String propertyValue) {
751         firstVevent.setPropertyValue(propertyName, propertyValue);
752     }
753 
754     /**
755      * Add property on first VEVENT.
756      *
757      * @param vProperty property object
758      */
759     public void addFirstVeventProperty(VProperty vProperty) {
760         firstVevent.addProperty(vProperty);
761     }
762 
763     /**
764      * Check if this item is a VTODO item
765      *
766      * @return true with VTODO items
767      */
768     public boolean isTodo() {
769         return firstVevent != null && "VTODO".equals(firstVevent.type);
770     }
771 
772     /**
773      * Return calendar mailbox address
774      * @return calendar email
775      */
776     public String getCalendarEmail() {
777         return email;
778     }
779 
780     public void setEmail(String email) {
781         this.email = email;
782     }
783 
784     /**
785      * VCalendar recipients for notifications
786      */
787     public static class Recipients {
788         /**
789          * attendee list
790          */
791         public String attendees;
792 
793         /**
794          * optional attendee list
795          */
796         public String optionalAttendees;
797 
798         /**
799          * vCalendar organizer
800          */
801         public String organizer;
802     }
803 
804     /**
805      * Build recipients value for VCalendar.
806      *
807      * @param isNotification if true, filter recipients that should receive meeting notifications
808      * @return notification/event recipients
809      */
810     public Recipients getRecipients(boolean isNotification) {
811 
812         HashSet<String> attendees = new HashSet<>();
813         HashSet<String> optionalAttendees = new HashSet<>();
814 
815         // get recipients from first VEVENT
816         List<VProperty> attendeeProperties = getFirstVeventProperties("ATTENDEE");
817         if (attendeeProperties != null) {
818             for (VProperty property : attendeeProperties) {
819                 // exclude current user and invalid values from recipients
820                 // also exclude no action attendees
821                 String attendeeEmail = getEmailValue(property);
822                 if (!email.equalsIgnoreCase(attendeeEmail) && attendeeEmail != null && attendeeEmail.indexOf('@') >= 0
823                         // return all attendees for user calendar folder, filter for notifications
824                         && (!isNotification
825                         // notify attendee if reply explicitly requested
826                         || (property.hasParam("RSVP", "TRUE"))
827                         || (
828                         // workaround for iCal bug: do not notify if reply explicitly not requested
829                         !(property.hasParam("RSVP", "FALSE")) &&
830                                 ((property.hasParam("PARTSTAT", "NEEDS-ACTION")
831                                         // need to include other PARTSTATs participants for CANCEL notifications
832                                         || property.hasParam("PARTSTAT", "ACCEPTED")
833                                         || property.hasParam("PARTSTAT", "DECLINED")
834                                         || property.hasParam("PARTSTAT", "TENTATIVE")))
835                 ))) {
836                     if (property.hasParam("ROLE", "OPT-PARTICIPANT")) {
837                         optionalAttendees.add(attendeeEmail);
838                     } else {
839                         attendees.add(attendeeEmail);
840                     }
841                 }
842             }
843         }
844         Recipients recipients = new Recipients();
845         recipients.organizer = getEmailValue(getFirstVeventProperty("ORGANIZER"));
846         recipients.attendees = StringUtil.join(attendees, ", ");
847         recipients.optionalAttendees = StringUtil.join(optionalAttendees, ", ");
848         return recipients;
849     }
850 
851     public String getAttendeeStatus() {
852         String attendeeStatus = null;
853         // iterate over all Vevents to detect meeting response
854         for (VObject vObject : vObjects) {
855             if ("VEVENT".equals(vObject.type)) {
856                 List<VProperty> attendeeProperties = vObject.getProperties("ATTENDEE");
857                 if (attendeeProperties != null) {
858                     for (VProperty property : attendeeProperties) {
859                         if (email.equalsIgnoreCase(getEmailValue(property))) {
860                             String status = property.getParamValue("PARTSTAT");
861                             if (!"NEEDS-ACTION".equals(status)) {
862                                 attendeeStatus = status;
863                             }
864                         }
865                     }
866                 }
867             }
868         }
869         return attendeeStatus;
870     }
871 
872     /**
873      * Get first VEvent
874      *
875      * @return first VEvent
876      */
877     public VObject getFirstVevent() {
878         return firstVevent;
879     }
880 
881     /**
882      * Get recurring VCalendar occurrence exceptions.
883      *
884      * @return event occurrences
885      */
886     public List<VObject> getModifiedOccurrences() {
887         boolean first = true;
888         ArrayList<VObject> results = new ArrayList<>();
889         for (VObject vObject : vObjects) {
890             if ("VEVENT".equals(vObject.type)) {
891                 if (first) {
892                     first = false;
893                 } else {
894                     results.add(vObject);
895                 }
896             }
897         }
898         return results;
899     }
900 
901     public TimeZone getStandardTimezoneId(String tzid) {
902         String convertedTzid;
903         // convert Exchange TZID to standard timezone
904         try {
905             convertedTzid = ResourceBundle.getBundle("timezones").getString(tzid);
906         } catch (MissingResourceException e) {
907             convertedTzid = tzid;
908             // failover: detect timezone from offset
909             VObject vTimezone = getVTimezone();
910             for (VObject tzDefinition : vTimezone.vObjects) {
911                 if ("STANDARD".equals(tzDefinition.type)) {
912                     convertedTzid = getTzidFromOffset(tzDefinition.getPropertyValue("TZOFFSETTO"));
913                 }
914             }
915             convertedTzid = ResourceBundle.getBundle("timezones").getString(convertedTzid);
916         }
917         return TimeZone.getTimeZone(convertedTzid);
918 
919     }
920 
921     private String getTzidFromOffset(String tzOffset) {
922         if (tzOffset == null) {
923             return null;
924         } else if (tzOffset.length() == 7) {
925             tzOffset = tzOffset.substring(0, 5);
926         }
927         return ResourceBundle.getBundle("tzoffsettimezones").getString(tzOffset);
928     }
929 
930     public String convertCalendarDateToExchangeZulu(String vcalendarDateValue, String tzid) throws IOException {
931         String zuluDateValue = null;
932         TimeZone timeZone;
933         if (tzid == null) {
934             timeZone = ExchangeSession.GMT_TIMEZONE;
935         } else {
936             timeZone = getStandardTimezoneId(tzid);
937         }
938         if (vcalendarDateValue != null) {
939             try {
940                 SimpleDateFormat dateParser;
941                 if (vcalendarDateValue.length() == 8) {
942                     dateParser = new SimpleDateFormat("yyyyMMdd", Locale.ENGLISH);
943                 } else {
944                     dateParser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.ENGLISH);
945                 }
946                 if (vcalendarDateValue.endsWith("Z")) {
947                     // date value is Zulu, ignore provided timezone
948                     dateParser.setTimeZone(TimeZone.getTimeZone("UTC"));
949                 } else {
950                     dateParser.setTimeZone(timeZone);
951                 }
952                 dateParser.setTimeZone(timeZone);
953                 SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH);
954                 dateFormatter.setTimeZone(ExchangeSession.GMT_TIMEZONE);
955                 zuluDateValue = dateFormatter.format(dateParser.parse(vcalendarDateValue));
956             } catch (ParseException e) {
957                 throw new IOException("Invalid date " + vcalendarDateValue + " with tzid " + tzid);
958             }
959         }
960         return zuluDateValue;
961     }
962 
963     /**
964      * Convert date format, keep timezone.
965      * @param vcalendarDateValue input date in ics format
966      * @param tzid ics timezone id
967      * @return converted date
968      * @throws IOException on error
969      */
970     public String convertCalendarDateToGraph(String vcalendarDateValue, String tzid) throws IOException {
971         String graphDateValue = null;
972         TimeZone timeZone;
973         if (tzid == null) {
974             timeZone = ExchangeSession.GMT_TIMEZONE;
975         } else {
976             timeZone = getStandardTimezoneId(tzid);
977         }
978         if (vcalendarDateValue != null) {
979             try {
980                 SimpleDateFormat dateParser;
981                 if (vcalendarDateValue.length() == 8) {
982                     dateParser = new SimpleDateFormat("yyyyMMdd", Locale.ENGLISH);
983                 } else {
984                     dateParser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.ENGLISH);
985                 }
986                 dateParser.setTimeZone(timeZone);
987                 SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH);
988                 dateFormatter.setTimeZone(timeZone);
989                 graphDateValue = dateFormatter.format(dateParser.parse(vcalendarDateValue));
990             } catch (ParseException e) {
991                 throw new IOException("Invalid date " + vcalendarDateValue + " with tzid " + tzid);
992             }
993         }
994         return graphDateValue;
995     }
996 
997 }