diff --git a/lib/kolab_sync_data_calendar.php b/lib/kolab_sync_data_calendar.php --- a/lib/kolab_sync_data_calendar.php +++ b/lib/kolab_sync_data_calendar.php @@ -116,6 +116,14 @@ const SENSITIVITY_PRIVATE = 2; const SENSITIVITY_CONFIDENTIAL = 3; + /** + * Internal iTip states + */ + const ITIP_ACCEPTED = 'ACCEPTED'; + const ITIP_DECLINED = 'DECLINED'; + const ITIP_TENTATIVE = 'TENTATIVE'; + const ITIP_CANCELLED = 'CANCELLED'; + const KEY_DTSTAMP = 'x-custom.X-ACTIVESYNC-DTSTAMP'; const KEY_REPLYTIME = 'x-custom.X-ACTIVESYNC-REPLYTIME'; @@ -746,6 +754,147 @@ return $this->serverId($event['uid'], $folderId); } + /** + * Process an event from an iTip message - update the event in the recipient's calendar + * + * @param array $event Event data from the iTip + * + * @return string|null Attendee status from the iTip (self::ITIP_* constant value) + */ + public function processItipReply($event) + { + // FIXME: This does not prevent from spoofing, i.e. an iTip message + // could be sent by anyone impersonating an organizer or attendee + + // FIXME: This will not work with Kolab delegation, as we do look + // for the event instance in personal folders only (for now) + // We also do not use SENT-BY,DELEGATED-TO,DELEGATED-FROM here at all. + + // FIXME: This is potential performance problem - we update an event + // whenever we sync an email message. User can have multiple AC clients + // or many iTip messages in INBOX. Should we remember which email was + // already processed? + + // FIXME: Should we check SEQUENCE or something else to prevent + // overwriting the attendee status with outdated status (on REPLY)? + + // Here we're handling CANCEL message, find the event (or occurrence) and remove it + if ($event['_method'] == 'CANCEL') { + // TODO: Performance: When we're going to delete the event we don't have to fetch it, + // we just need to find that it exists and in which folder. + + if ($existing = $this->find_event_by_uid($event['uid'])) { + // Note: Normally we'd just set the event status to canceled, but + // ActiveSync clients do not understand that, we have to delete it + + if (!empty($event['recurrence_date'])) { + // A single recurring event occurrence + $rec_day = $event['recurrence_date']->format('Ymd'); + // Remove the matching RDATE entry + if (!empty($existing['recurrence']['RDATE'])) { + foreach ($existing['recurrence']['RDATE'] as $j => $rdate) { + if ($rdate->format('Ymd') == $rec_day) { + unset($existing['recurrence']['RDATE'][$j]); + break; + } + } + } + + // Check EXDATE list, maybe already cancelled + if (!empty($existing['recurrence']['EXDATE'])) { + foreach ($existing['recurrence']['EXDATE'] as $j => $exdate) { + if ($exdate->format('Ymd') == $rec_day) { + return self::ITIP_CANCELLED; // skip update + } + } + } else { + $existing['recurrence']['EXDATE'] = []; + } + + if (!isset($existing['exceptions'])) { + $existing['exceptions'] = []; + } + + if (!empty($existing['exceptions'])) { + foreach ($existing['exceptions'] as $i => $exception) { + if (libcalendaring::is_recurrence_exception($event, $exception)) { + unset($existing['exceptions'][$i]); + } + } + } + + // Add an exception to the master event + $existing['recurrence']['EXDATE'][] = $event['recurrence_date']; + + // TODO: Handle errors + $this->save_event($existing, null); + } + else { + $folder = $this->getFolderObject($existing['_mailbox']); + if ($folder && $folder->valid) { + // TODO: Handle errors + $folder->delete($event['uid']); + } + } + } + + return self::ITIP_CANCELLED; + } + + // Here we're handling REPLY message + if (empty($event['attendees']) || $event['_method'] != 'REPLY') { + return null; + } + + $attendeeStatus = null; + $attendeeEmail = null; + + // Get the attendee/status + foreach ($event['attendees'] as $attendee) { + if (empty($attendee['role']) || $attendee['role'] != 'ORGANIZER') { + if (!empty($attendee['email']) && !empty($attendee['status'])) { + // Per iTip spec. there should be only one (non-organizer) attendee here + // FIXME: Verify is it realy the case with e.g. Kolab webmail, If not, we should + // probably use the message sender from the From: header + $attendeeStatus = strtoupper($attendee['status']); + $attendeeEmail = $attendee['email']; + break; + } + } + } + + // Find the event (or occurrence) and update it + if ($attendeeStatus && ($existing = $this->find_event_by_uid($event['uid']))) { + // TODO: We should probably check the SEQUENCE to not reset status to an outdated value + + if (!empty($event['recurrence_date'])) { + // A single recurring event occurrence + // Find the exception entry, it should exist, if not ignore + if (!empty($existing['exceptions'])) { + foreach ($existing['exceptions'] as $i => $exception) { + if (!empty($exception['attendees']) && libcalendaring::is_recurrence_exception($event, $exception)) { + $attendees = &$existing['exceptions'][$i]['attendees']; + break; + } + } + } + } + else if (!empty($existing['attendees'])) { + $attendees = &$existing['attendees']; + } + + if (isset($attendees)) { + $found = $this->find_and_update_attendee_status($attendees, $attendeeStatus, [$attendeeEmail], $changed); + if ($found && $changed) { + // TODO: error handling + $this->save_event($existing, null); + } + } + } + + return $attendeeStatus; + } + /** * Get an event from the invitation email or calendar folder */ @@ -811,21 +960,60 @@ throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::INVALID_REQUEST); } - if ($event['free_busy']) { - $old['free_busy'] = $event['free_busy']; + // A single recurring event occurrence + if (!empty($event['recurrence_date'])) { + $event['recurrence'] = []; + + if ($status) { + $this->update_attendee_status($event, $status); + $status = null; + } + + if (!isset($old['exceptions'])) { + $old['exceptions'] = []; + } + + $existing = false; + foreach ($old['exceptions'] as $i => $exception) { + if (libcalendaring::is_recurrence_exception($event, $exception)) { + $old['exceptions'][$i] = $event; + $existing = true; + } + } + + // TODO: In case organizer first cancelled an occurrence and then invited + // an attendee to the same date, and attendee accepts, we should remove EXDATE entry. + // FIXME: We have to check with ActiveSync clients whether it is better + // to have an exception with DECLINED attendee status, or an EXDATE entry + + if (!$existing) { + $old['exceptions'][] = $event; + } + } + // A main event update + else if (isset($event['sequence']) && $event['sequence'] > $old['sequence']) { + // FIXME: Can we be smarter here? Should we update everything? What about e.g. new attendees? + // And do we need to check the sequence? + $props = ['start', 'end', 'title', 'description', 'location', 'free_busy']; + + foreach ($props as $prop) { + if (isset($event[$prop])) { + $old[$prop] = $event[$prop]; + } + } + + // Copy new custom properties + if (!empty($event['x-custom'])) { + foreach ($event['x-custom'] as $key => $val) { + $old['x-custom'][$key] = $val; + } + } } // Updating an existing event is most-likely a response // to an iTip request with bumped SEQUENCE $old['sequence'] = ($old['sequence'] ?? 0) + 1; - // Copy new custom properties - if (!empty($event['x-custom'])) { - foreach ($event['x-custom'] as $key => $val) { - $old['x-custom'][$key] = $val; - } - } - // Update the event return $this->save_event($old, $status); } @@ -881,7 +1069,7 @@ /** * Update the attendee status of the user matching $emails */ - protected function find_and_update_attendee_status(&$attendees, $status, $emails) + protected function find_and_update_attendee_status(&$attendees, $status, $emails, &$changed = false) { $found = false; foreach ((array) $attendees as $i => $attendee) { @@ -889,6 +1077,7 @@ && (empty($attendee['role']) || $attendee['role'] != 'ORGANIZER') && in_array_nocase($attendee['email'], $emails) ) { + $changed = $changed || ($status != ($attendee['status'] ?? '')); $attendees[$i]['status'] = $status; $attendees[$i]['rsvp'] = false; $this->logger->debug('Updating existing attendee: ' . $attendee['email'] . ' status: ' . $status); diff --git a/lib/kolab_sync_data_email.php b/lib/kolab_sync_data_email.php --- a/lib/kolab_sync_data_email.php +++ b/lib/kolab_sync_data_email.php @@ -393,6 +393,8 @@ $result['messageClass'] = 'IPM.Note.SMIME'; } else if ($event = $this->get_invitation_event_from_message($message)) { + // Note: Depending on MessageClass a client will display a proper set of buttons + // Either Accept/Maybe/Decline (REQUEST), or "Remove from Calendar" (CANCEL) or none (REPLY). $result['messageClass'] = 'IPM.Schedule.Meeting.Request'; $result['contentClass'] = 'urn:content-classes:calendarmessage'; @@ -420,7 +422,7 @@ // Organizer if (!empty($event['attendees'])) { - foreach ($event['attendees'] as $idx => $attendee) { + foreach ($event['attendees'] as $attendee) { if (!empty($attendee['role']) && $attendee['role'] == 'ORGANIZER' && !empty($attendee['email'])) { $meeting['organizer'] = $attendee['email']; break; @@ -442,7 +444,6 @@ 'now' => $fileTime, ]); - // TODO handle other methods if ($event['_method'] == 'REQUEST') { $meeting['meetingMessageType'] = Syncroton_Model_EmailMeetingRequest::MESSAGE_TYPE_REQUEST; @@ -451,15 +452,59 @@ // parameter value of "NEEDS-ACTION" in the request". I think it is safe to do this for all requests. // Note: This does not have impact on the existence of Accept/Decline buttons in the client. $meeting['responseRequested'] = 1; - } else { + } else { // REPLY or CANCEL $meeting['meetingMessageType'] = Syncroton_Model_EmailMeetingRequest::MESSAGE_TYPE_NORMAL; + + $itip_processing = kolab_sync::get_instance()->config->get('activesync_itip_processing'); + if ($itip_processing && empty($headers->flags['SEEN'])) { + // Optionally process the message and update the event in recipient's calendar + // Warning: Only for development purposes, for now it's better to use wallace + $calendar_class = new kolab_sync_data_calendar($this->device, $this->syncTimeStamp); + $attendeeStatus = $calendar_class->processItipReply($event); + } + else if ($event['_method'] == 'CANCEL') { + $attendeeStatus = kolab_sync_data_calendar::ITIP_CANCELLED; + } + else if (!empty($event['attendees'])) { + // Get the attendee/status in the REPLY + foreach ($event['attendees'] as $attendee) { + if (empty($attendee['role']) || $attendee['role'] != 'ORGANIZER') { + if (!empty($attendee['email']) && !empty($attendee['status'])) { + // Per iTip spec. there should be only one (non-organizer) attendee here + // FIXME: Verify is it realy the case with e.g. Kolab webmail, If not, we should + // probably use the message sender from the From: header + $attendeeStatus = strtoupper($attendee['status']); + break; + } + } + } + } + + switch ($attendeeStatus) { + case kolab_sync_data_calendar::ITIP_CANCELLED: + $result['messageClass'] = 'IPM.Schedule.Meeting.Canceled'; + break; + case kolab_sync_data_calendar::ITIP_DECLINED: + $result['messageClass'] = 'IPM.Schedule.Meeting.Resp.Neg'; + break; + case kolab_sync_data_calendar::ITIP_TENTATIVE: + $result['messageClass'] = 'IPM.Schedule.Meeting.Resp.Tent'; + break; + case kolab_sync_data_calendar::ITIP_ACCEPTED: + $result['messageClass'] = 'IPM.Schedule.Meeting.Resp.Pos'; + break; + default: + $skipRequest = true; + } } // New time proposals aren't supported by Kolab. // This disables the UI elements related to this on the client side $meeting['disallowNewTimeProposal'] = 1; - $result['meetingRequest'] = new Syncroton_Model_EmailMeetingRequest($meeting); + if (empty($skipRequest)) { + $result['meetingRequest'] = new Syncroton_Model_EmailMeetingRequest($meeting); + } } // Categories (Tags) @@ -1430,6 +1475,11 @@ $libcal->mail_message_load(array('object' => $message)); $ical_objects = $libcal->get_mail_ical_objects(); + // Skip methods we do not support here + if (!in_array($ical_objects->method, ['REQUEST', 'CANCEL', 'REPLY'])) { + return null; + } + // We support only one event in the iTip foreach ($ical_objects as $mime_id => $event) { if ($event['_type'] == 'event') {