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