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