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