component; $instances = []; foreach ($itipMessage->message->$componentType as $component) { $instanceId = isset($component->{'RECURRENCE-ID'}) ? $component->{'RECURRENCE-ID'}->getValue() : 'base'; $instances[$instanceId] = $component; } // any existing instances should be marked as cancelled foreach ($existingObject->$componentType as $component) { $instanceId = isset($component->{'RECURRENCE-ID'}) ? $component->{'RECURRENCE-ID'}->getValue() : 'base'; if (isset($instances[$instanceId])) { if (isset($component->STATUS)) { $component->STATUS->setValue('CANCELLED'); } else { $component->add('STATUS', 'CANCELLED'); } if (isset($component->SEQUENCE)) { $component->SEQUENCE->setValue($itipMessage->sequence); } else { $component->add('SEQUENCE', $itipMessage->sequence); } unset($instances[$instanceId]); } } // any remaining instances are new and should be added foreach ($instances as $instance) { $existingObject->add($instance); } return $existingObject; } /** * This method is used in cases where an event got updated, and we * potentially need to send emails to attendees to let them know of updates * in the events. * * We will detect which attendees got added, which got removed and create * specific messages for these situations. * * @return array */ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, array $oldEventInfo) { $messages = []; // construct template calendar from original calendar without components $template = new VCalendar(); foreach ($template->children() as $property) { $template->remove($property); } foreach ($calendar->children() as $property) { if (in_array($property->name, ['METHOD', 'VEVENT', 'VTODO', 'VJOURNAL', 'VFREEBUSY'], true) === false) { $template->add(clone $property); } } // extract event information $objectId = $eventInfo['uid']; if ($calendar->getBaseComponent() === null) { $objectType = $calendar->getComponents()[0]->name; } else { $objectType = $calendar->getBaseComponent()->name; } $objectSequence = $eventInfo['sequence'] ?? 1; $organizerHref = $eventInfo['organizer'] ?? $oldEventInfo['organizer']; if ($eventInfo['organizerName'] instanceof \Sabre\VObject\Parameter) { $organizerName = $eventInfo['organizerName']->getValue(); } else { $organizerName = $eventInfo['organizerName']; } // detect if the singleton or recurring base instance was converted to non-scheduling if (count($eventInfo['instances']) === 0 && count($oldEventInfo['instances']) > 0) { foreach ($oldEventInfo['attendees'] as $attendee) { $messages[] = $this->generateMessage( $oldEventInfo['instances'], $organizerHref, $organizerName, $attendee, $objectId, $objectType, $objectSequence, 'CANCEL', $template ); } return $messages; } // detect if the singleton or recurring base instance was cancelled if ($eventInfo['instances']['master']?->STATUS?->getValue() === 'CANCELLED' && $oldEventInfo['instances']['master']?->STATUS?->getValue() !== 'CANCELLED') { foreach ($eventInfo['attendees'] as $attendee) { $messages[] = $this->generateMessage( $eventInfo['instances'], $organizerHref, $organizerName, $attendee, $objectId, $objectType, $objectSequence, 'CANCEL', $template ); } return $messages; } // detect if a new cancelled instance was created $cancelledNewInstances = []; if (isset($oldEventInfo['instances'])) { $instancesDelta = array_diff_key($eventInfo['instances'], $oldEventInfo['instances']); foreach ($instancesDelta as $id => $instance) { if ($instance->STATUS?->getValue() === 'CANCELLED') { $cancelledNewInstances[] = $id; foreach ($eventInfo['attendees'] as $attendee) { $messages[] = $this->generateMessage( [$id => $instance], $organizerHref, $organizerName, $attendee, $objectId, $objectType, $objectSequence, 'CANCEL', $template ); } } } } // detect attendee mutations $attendees = array_unique( array_merge( array_keys($eventInfo['attendees']), array_keys($oldEventInfo['attendees']) ) ); foreach ($attendees as $attendee) { // Skip organizer if ($attendee === $organizerHref) { continue; } // Skip if SCHEDULE-AGENT=CLIENT (respect RFC 6638) if ($this->scheduleAgentServerRules && isset($eventInfo['attendees'][$attendee]['scheduleAgent']) && strtoupper($eventInfo['attendees'][$attendee]['scheduleAgent']) === 'CLIENT') { continue; } // detect if attendee was removed and send cancel message if (!isset($eventInfo['attendees'][$attendee]) && isset($oldEventInfo['attendees'][$attendee])) { //get all instances of the attendee was removed from. $instances = array_intersect_key($oldEventInfo['instances'], array_flip(array_keys($oldEventInfo['attendees'][$attendee]['instances']))); $messages[] = $this->generateMessage( $instances, $organizerHref, $organizerName, $oldEventInfo['attendees'][$attendee], $objectId, $objectType, $objectSequence, 'CANCEL', $template ); continue; } // otherwise any created or modified instances will be sent as REQUEST $instances = array_intersect_key($eventInfo['instances'], array_flip(array_keys($eventInfo['attendees'][$attendee]['instances']))); // Remove already-cancelled new instances from REQUEST if (!empty($cancelledNewInstances)) { $instances = array_diff_key($instances, array_flip($cancelledNewInstances)); } // Skip if no instances left to send if (empty($instances)) { continue; } // Add EXDATE for instances the attendee is NOT part of (only for recurring events with master) if (isset($instances['master']) && count($eventInfo['instances']) > 1) { $masterInstance = clone $instances['master']; $excludedDates = []; foreach ($eventInfo['instances'] as $instanceId => $instance) { if ($instanceId !== 'master' && !isset($eventInfo['attendees'][$attendee]['instances'][$instanceId])) { $excludedDates[] = $instance->{'RECURRENCE-ID'}->getValue(); } } if (!empty($excludedDates)) { if (isset($masterInstance->EXDATE)) { $currentExdates = $masterInstance->EXDATE->getParts(); $masterInstance->EXDATE->setParts(array_merge($currentExdates, $excludedDates)); } else { $masterInstance->EXDATE = $excludedDates; } $instances['master'] = $masterInstance; } } $messages[] = $this->generateMessage( $instances, $organizerHref, $organizerName, $eventInfo['attendees'][$attendee], $objectId, $objectType, $objectSequence, 'REQUEST', $template ); } return $messages; } /** * Generates an iTip message for a specific attendee * * @param array $instances Array of event instances to include, keyed by instance ID: * - 'master' => Component: The master/base event * - '{RECURRENCE-ID}' => Component: Exception instances * @param string $organizerHref The organizer's calendar-user address (e.g., 'mailto:user@example.com') * @param string|null $organizerName The organizer's display name * @param array $attendee The attendee information containing: * - 'href' (string): The attendee's calendar-user address * - 'name' (string): The attendee's display name * - 'scheduleAgent' (string|null): SCHEDULE-AGENT parameter * - 'instances' (array): Instances this attendee is part of * @param string $objectId The UID of the event * @param string $objectType The component type ('VEVENT', 'VTODO', etc.) * @param int $objectSequence The sequence number of the event * @param string $method The iTip method ('REQUEST', 'CANCEL', 'REPLY', etc.) * @param VCalendar $template The template calendar object (without event components) * @return Message The generated iTip message ready to be sent */ protected function generateMessage( array $instances, string $organizerHref, ?string $organizerName, array $attendee, string $objectId, string $objectType, int $objectSequence, string $method, VCalendar $template, ): Message { $recipientAddress = $attendee['href'] ?? ''; $recipientName = $attendee['name'] ?? ''; $vObject = clone $template; if ($vObject->METHOD && $vObject->METHOD->getValue() !== $method) { $vObject->METHOD->setValue($method); } else { $vObject->add('METHOD', $method); } foreach ($instances as $instance) { $vObject->add($this->componentSanitizeScheduling(clone $instance)); } $message = new Message(); $message->method = $method; $message->uid = $objectId; $message->component = $objectType; $message->sequence = $objectSequence; $message->sender = $organizerHref; $message->senderName = $organizerName; $message->recipient = $recipientAddress; $message->recipientName = $recipientName; $message->significantChange = true; $message->message = $vObject; return $message; } protected function componentSanitizeScheduling(Component $component): Component { // Cleaning up any scheduling information that should not be sent or is missing unset($component->ORGANIZER['SCHEDULE-FORCE-SEND'], $component->ORGANIZER['SCHEDULE-STATUS']); foreach ($component->ATTENDEE as $attendee) { unset($attendee['SCHEDULE-FORCE-SEND'], $attendee['SCHEDULE-STATUS']); if (!isset($attendee['PARTSTAT'])) { $attendee['PARTSTAT'] = 'NEEDS-ACTION'; } } // Sequence is a required property, default is 0 // https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.7.4 if ($component->SEQUENCE === null) { $component->add('SEQUENCE', 0); } return $component; } }