diff --git a/apps/dav/lib/AppInfo/Application.php b/apps/dav/lib/AppInfo/Application.php index 69094f6da8470..6b0f22a5022a5 100644 --- a/apps/dav/lib/AppInfo/Application.php +++ b/apps/dav/lib/AppInfo/Application.php @@ -19,6 +19,7 @@ use OCA\DAV\CalDAV\Reminder\NotificationProvider\PushProvider; use OCA\DAV\CalDAV\Reminder\NotificationProviderManager; use OCA\DAV\CalDAV\Reminder\Notifier as NotifierCalDAV; +use OCA\DAV\CalDAV\TipBroker; use OCA\DAV\Capabilities; use OCA\DAV\CardDAV\ContactsManager; use OCA\DAV\CardDAV\Notification\Notifier as NotifierCardDAV; @@ -108,6 +109,7 @@ use OCP\User\Events\UserIdUnassignedEvent; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +use Sabre\VObject; use Throwable; use function is_null; @@ -238,6 +240,8 @@ public function register(IRegistrationContext $context): void { #[\Override] public function boot(IBootContext $context): void { + VObject\Component\VCalendar::$propertyMap[TipBroker::INVITATION_FORWARDING_PROPERTY] = VObject\Property\Boolean::class; + // Load all dav apps $context->getServerContainer()->get(IAppManager::class)->loadApps(['dav']); diff --git a/apps/dav/lib/CalDAV/TipBroker.php b/apps/dav/lib/CalDAV/TipBroker.php index 0bc48c2922ae7..fad529b0bc87a 100644 --- a/apps/dav/lib/CalDAV/TipBroker.php +++ b/apps/dav/lib/CalDAV/TipBroker.php @@ -11,10 +11,18 @@ use Sabre\VObject\Component; use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; use Sabre\VObject\ITip\Broker; use Sabre\VObject\ITip\Message; +use Sabre\VObject\Parameter; +use Sabre\VObject\Property; +use Sabre\VObject\Property\Boolean; +use Sabre\VObject\Property\ICalendar\CalAddress; +use Sabre\VObject\Property\ICalendar\DateTime; +use Sabre\VObject\Recur\EventIterator; class TipBroker extends Broker { + public const INVITATION_FORWARDING_PROPERTY = 'X-NC-INVITATION-FORWARDING'; public $significantChangeProperties = [ 'DTSTART', @@ -79,6 +87,191 @@ protected function processMessageCancel(Message $itipMessage, ?VCalendar $existi return $existingObject; } + #[\Override] + protected function processMessageReply(Message $itipMessage, ?VCalendar $existingObject = null) { + // A reply can only be processed based on an existing object. + // If the object is not available, the reply is ignored. + if ($existingObject === null) { + return null; + } + $instances = []; + $requestStatus = '2.0'; + + /** @var list $vevents */ + $vevents = $itipMessage->message->select('VEVENT'); + + // Finding all the instances the attendee replied to. + foreach ($vevents as $vevent) { + // Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence. + // The Unix timestamp will be the same for an event, even if the reply from the attendee + // used a different format/timezone to express the event date-time. + $recurId = $this->getRecurrenceKey($vevent); + $attendee = $this->getFirstAttendee($vevent); + if ($attendee === null) { + continue; + } + $partstat = $attendee->offsetGet('PARTSTAT'); + if (!$partstat instanceof Parameter) { + continue; + } + $instances[$recurId] = $partstat->getValue(); + if (isset($vevent->{'REQUEST-STATUS'})) { + $requestStatus = $vevent->{'REQUEST-STATUS'}->getValue(); + [$requestStatus] = explode(';', $requestStatus); + } + } + + // Now we need to loop through the original organizer event, to find + // all the instances where we have a reply for. + $masterObject = $this->getMasterEvent($existingObject); + $masterAllowInvitationForwarding = $masterObject === null || $this->allowInvitationForwarding($masterObject); + + /** @var list $vevents */ + $vevents = $existingObject->select('VEVENT'); + + foreach ($vevents as $vevent) { + // Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence. + $recurId = $this->getRecurrenceKey($vevent); + if (isset($instances[$recurId])) { + $allowInvitationForwarding = $this->allowInvitationForwarding($vevent); + $attendeeFound = false; + if (isset($vevent->ATTENDEE)) { + foreach ($vevent->ATTENDEE as $attendee) { + if ($attendee->getValue() === $itipMessage->sender) { + $attendeeFound = true; + $attendee['PARTSTAT'] = $instances[$recurId]; + $attendee['SCHEDULE-STATUS'] = $requestStatus; + // Un-setting the RSVP status, because we now know + // that the attendee already replied. + unset($attendee['RSVP']); + break; + } + } + } + if (!$attendeeFound && $allowInvitationForwarding) { + // Adding a new attendee. The iTip documentation calls this + // a party crasher. + $parameters = [ + 'PARTSTAT' => $instances[$recurId], + ]; + if ($itipMessage->senderName) { + $parameters['CN'] = $itipMessage->senderName; + } + $vevent->add('ATTENDEE', $itipMessage->sender, $parameters); + } + unset($instances[$recurId]); + } + } + + if ($masterObject === null) { + // No master object, we can't add new instances. + return null; + } + // If we got replies to instances that did not exist in the + // original list, it means that new exceptions must be created. + foreach ($instances as $recurId => $partstat) { + $recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid); + $found = false; + $iterations = 1000; + do { + $newObject = $recurrenceIterator->getEventObject(); + $recurrenceIterator->next(); + + // Compare the Unix timestamp returned by getTimestamp with the previously calculated timestamp. + // If they are the same, then this is a matching recurrence, even though its date-time may have + // been expressed in a different format/timezone. + if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() === $recurId) { + $found = true; + } + --$iterations; + } while ($recurrenceIterator->valid() && !$found && $iterations); + + // Invalid recurrence id. Skipping this object. + if (!$found) { + continue; + } + + $newObject->remove('RRULE'); + $newObject->remove('EXDATE'); + $newObject->remove('RDATE'); + + $attendeeFound = false; + if (isset($newObject->ATTENDEE)) { + foreach ($newObject->ATTENDEE as $attendee) { + if ($attendee->getValue() === $itipMessage->sender) { + $attendeeFound = true; + $attendee['PARTSTAT'] = $partstat; + $attendee['SCHEDULE-STATUS'] = $requestStatus; + unset($attendee['RSVP']); + break; + } + } + } + if (!$attendeeFound && !$masterAllowInvitationForwarding) { + continue; + } + if (!$attendeeFound) { + // Adding a new attendee + $parameters = [ + 'PARTSTAT' => $partstat, + ]; + if ($itipMessage->senderName) { + $parameters['CN'] = $itipMessage->senderName; + } + $newObject->add('ATTENDEE', $itipMessage->sender, $parameters); + } + $existingObject->add($newObject); + } + + return $existingObject; + } + + protected function getMasterEvent(VCalendar $calendar): ?VEvent { + /** @var list $vevents */ + $vevents = $calendar->select('VEVENT'); + foreach ($vevents as $vevent) { + if (!isset($vevent->{'RECURRENCE-ID'})) { + return $vevent; + } + } + return null; + } + + /** + * @return int|'master' + */ + protected function getRecurrenceKey(VEvent $vevent): int|string { + /** @var list $recurrenceIds */ + $recurrenceIds = $vevent->select('RECURRENCE-ID'); + foreach ($recurrenceIds as $recurrenceId) { + if ($recurrenceId instanceof DateTime) { + return $recurrenceId->getDateTime()->getTimestamp(); + } + } + return 'master'; + } + + protected function getFirstAttendee(VEvent $vevent): ?CalAddress { + /** @var list $attendees */ + $attendees = $vevent->select('ATTENDEE'); + foreach ($attendees as $attendee) { + if ($attendee instanceof CalAddress) { + return $attendee; + } + } + return null; + } + + protected function allowInvitationForwarding(VEvent $vevent): bool { + $properties = $vevent->select(self::INVITATION_FORWARDING_PROPERTY); + foreach ($properties as $property) { + if ($property instanceof Boolean) { + return $property->getValue() === 'TRUE'; + } + } + return true; + } + /** * 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 diff --git a/apps/dav/tests/unit/CalDAV/TipBrokerTest.php b/apps/dav/tests/unit/CalDAV/TipBrokerTest.php index 8843895012598..9d4c27c9bfbc0 100644 --- a/apps/dav/tests/unit/CalDAV/TipBrokerTest.php +++ b/apps/dav/tests/unit/CalDAV/TipBrokerTest.php @@ -8,7 +8,9 @@ namespace OCA\DAV\Tests\unit\CalDAV; use OCA\DAV\CalDAV\TipBroker; +use Sabre\VObject; use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\ITip\Message; use Test\TestCase; class TipBrokerTest extends TestCase { @@ -21,6 +23,8 @@ class TipBrokerTest extends TestCase { protected function setUp(): void { parent::setUp(); + VCalendar::$propertyMap[TipBroker::INVITATION_FORWARDING_PROPERTY] = VObject\Property\Boolean::class; + $this->broker = new TipBroker(); $this->templateEventInfo = [ @@ -41,8 +45,8 @@ protected function setUp(): void { $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']); $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']); $vEvent->add('SUMMARY', 'Test Event'); - $vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']); - $vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + $vEvent->add('ORGANIZER', 'mailto:organizer@example.org', ['CN' => 'Organizer']); + $vEvent->add('ATTENDEE', 'mailto:attendee1@example.org', [ 'CN' => 'Attendee One', 'CUTYPE' => 'INDIVIDUAL', 'PARTSTAT' => 'NEEDS-ACTION', @@ -65,8 +69,8 @@ protected function setUp(): void { $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']); $vEvent->add('RRULE', 'FREQ=WEEKLY;COUNT=12;BYDAY=MO'); $vEvent->add('SUMMARY', 'Test Event'); - $vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']); - $vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + $vEvent->add('ORGANIZER', 'mailto:organizer@example.org', ['CN' => 'Organizer']); + $vEvent->add('ATTENDEE', 'mailto:attendee1@example.org', [ 'CN' => 'Attendee One', 'CUTYPE' => 'INDIVIDUAL', 'PARTSTAT' => 'NEEDS-ACTION', @@ -75,6 +79,12 @@ protected function setUp(): void { ]); } + protected function tearDown(): void { + unset(VCalendar::$propertyMap[TipBroker::INVITATION_FORWARDING_PROPERTY]); + + parent::tearDown(); + } + /** * Tests user creating a new singleton or recurring event */ @@ -159,7 +169,7 @@ public function testParseEventForOrganizerAddAttendee(): void { $mutatedCalendar = clone $this->vCalendar1a; $mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); $mutatedCalendar->VEVENT->SEQUENCE->setValue(2); - $mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@testing.com', [ + $mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@example.org', [ 'CN' => 'Attendee Two', 'CUTYPE' => 'INDIVIDUAL', 'PARTSTAT' => 'NEEDS-ACTION', @@ -185,7 +195,7 @@ public function testParseEventForOrganizerAddAttendee(): void { public function testParseEventForOrganizerRemoveAttendee(): void { // construct calendar and generate event info for modified event with two attendees $originalCalendar = clone $this->vCalendar1a; - $originalCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@testing.com', [ + $originalCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@example.org', [ 'CN' => 'Attendee Two', 'CUTYPE' => 'INDIVIDUAL', 'PARTSTAT' => 'NEEDS-ACTION', @@ -197,7 +207,7 @@ public function testParseEventForOrganizerRemoveAttendee(): void { $mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z'); $mutatedCalendar->VEVENT->SEQUENCE->setValue(2); $mutatedCalendar->VEVENT->remove('ATTENDEE'); - $mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + $mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee1@example.org', [ 'CN' => 'Attendee One', 'CUTYPE' => 'INDIVIDUAL', 'PARTSTAT' => 'NEEDS-ACTION', @@ -214,7 +224,7 @@ public function testParseEventForOrganizerRemoveAttendee(): void { $this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient); $this->assertEquals('CANCEL', $messages[1]->method); $this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[1]->sender); - $this->assertEquals('mailto:attendee2@testing.com', $messages[1]->recipient); + $this->assertEquals('mailto:attendee2@example.org', $messages[1]->recipient); } @@ -377,7 +387,7 @@ public function testParseEventForOrganizerModifyInstanceAddAttendee(): void { $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); $mutatedInstance = clone $originalInstance; $mutatedInstance->SEQUENCE->setValue(2); - $mutatedInstance->add('ATTENDEE', 'mailto:attendee2@testing.com', [ + $mutatedInstance->add('ATTENDEE', 'mailto:attendee2@example.org', [ 'CN' => 'Attendee Two', 'CUTYPE' => 'INDIVIDUAL', 'PARTSTAT' => 'NEEDS-ACTION', @@ -417,7 +427,7 @@ public function testParseEventForOrganizerModifyInstanceRemoveAttendee(): void { $originalInstance->SEQUENCE->setValue(1); $originalInstance->DTSTART->setValue('20240717T080000'); $originalInstance->DTEND->setValue('20240717T090000'); - $originalInstance->add('ATTENDEE', 'mailto:attendee2@testing.com', [ + $originalInstance->add('ATTENDEE', 'mailto:attendee2@example.org', [ 'CN' => 'Attendee Two', 'CUTYPE' => 'INDIVIDUAL', 'PARTSTAT' => 'NEEDS-ACTION', @@ -429,7 +439,7 @@ public function testParseEventForOrganizerModifyInstanceRemoveAttendee(): void { $mutatedInstance = clone $originalInstance; $mutatedInstance->SEQUENCE->setValue(2); $mutatedInstance->remove('ATTENDEE'); - $mutatedInstance->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + $mutatedInstance->add('ATTENDEE', 'mailto:attendee1@example.org', [ 'CN' => 'Attendee One', 'CUTYPE' => 'INDIVIDUAL', 'PARTSTAT' => 'NEEDS-ACTION', @@ -579,4 +589,364 @@ public function testParseEventForOrganizerScheduleForceSend(): void { $this->assertFalse(isset($messages[0]->message->VEVENT->ATTENDEE['SCHEDULE-FORCE-SEND'])); } + public function testProcessMessageReplyDisallowsInvitationForwarding(): void { + $existingCalendar = clone $this->vCalendar1a; + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE'); + $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = 'mailto:attendee2@example.org'; + $reply->senderName = 'Attendee Two'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + $replyEvent->add('ATTENDEE', $reply->sender, [ + 'PARTSTAT' => 'ACCEPTED', + ]); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertSame($existingCalendar, $result); + $this->assertCount(1, $result->VEVENT->ATTENDEE); + $this->assertEquals('mailto:attendee1@example.org', $result->VEVENT->ATTENDEE[0]->getValue()); + } + + public function testProcessMessageReplyUpdatesExistingAttendeeWhenInvitationForwardingDisabled(): void { + $existingCalendar = clone $this->vCalendar1a; + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE'); + $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = 'mailto:attendee1@example.org'; + $reply->senderName = 'Attendee One'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + $replyEvent->add('ATTENDEE', $reply->sender, [ + 'PARTSTAT' => 'ACCEPTED', + ]); + $replyEvent->add('REQUEST-STATUS', '2.0;Success'); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertSame($existingCalendar, $result); + $this->assertCount(1, $result->VEVENT->ATTENDEE); + $this->assertEquals('mailto:attendee1@example.org', $result->VEVENT->ATTENDEE[0]->getValue()); + $this->assertEquals('ACCEPTED', $result->VEVENT->ATTENDEE[0]['PARTSTAT']->getValue()); + $this->assertEquals('2.0', $result->VEVENT->ATTENDEE[0]['SCHEDULE-STATUS']->getValue()); + $this->assertFalse(isset($result->VEVENT->ATTENDEE[0]['RSVP'])); + } + + public function testProcessMessageReplyAllowsInvitationForwarding(): void { + $existingCalendar = clone $this->vCalendar1a; + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'TRUE'); + $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = 'mailto:attendee2@example.org'; + $reply->senderName = 'Attendee Two'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + $replyEvent->add('ATTENDEE', $reply->sender, [ + 'PARTSTAT' => 'ACCEPTED', + ]); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertSame($existingCalendar, $result); + $this->assertCount(2, $result->VEVENT->ATTENDEE); + $this->assertEquals('mailto:attendee2@example.org', $result->VEVENT->ATTENDEE[1]->getValue()); + $this->assertEquals('ACCEPTED', $result->VEVENT->ATTENDEE[1]['PARTSTAT']->getValue()); + $this->assertEquals('Attendee Two', $result->VEVENT->ATTENDEE[1]['CN']->getValue()); + } + + public function testProcessMessageReplyAllowsInvitationForwardingByDefault(): void { + $existingCalendar = clone $this->vCalendar1a; + $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = 'mailto:attendee2@example.org'; + $reply->senderName = 'Attendee Two'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + $replyEvent->add('ATTENDEE', $reply->sender, [ + 'PARTSTAT' => 'ACCEPTED', + ]); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertSame($existingCalendar, $result); + $this->assertCount(2, $result->VEVENT->ATTENDEE); + $this->assertEquals('mailto:attendee2@example.org', $result->VEVENT->ATTENDEE[1]->getValue()); + $this->assertEquals('ACCEPTED', $result->VEVENT->ATTENDEE[1]['PARTSTAT']->getValue()); + $this->assertEquals('Attendee Two', $result->VEVENT->ATTENDEE[1]['CN']->getValue()); + } + + public function testProcessMessageReplyIgnoresReplyWithoutAttendee(): void { + $existingCalendar = clone $this->vCalendar1a; + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = 'mailto:attendee1@example.org'; + $reply->senderName = 'Attendee One'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertSame($existingCalendar, $result); + $this->assertCount(1, $result->VEVENT->ATTENDEE); + $this->assertEquals('NEEDS-ACTION', $result->VEVENT->ATTENDEE[0]['PARTSTAT']->getValue()); + $this->assertTrue(isset($result->VEVENT->ATTENDEE[0]['RSVP'])); + } + + public function testProcessMessageReplyIgnoresReplyWithoutPartstat(): void { + $existingCalendar = clone $this->vCalendar1a; + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = 'mailto:attendee1@example.org'; + $reply->senderName = 'Attendee One'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + $replyEvent->add('ATTENDEE', $reply->sender, []); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertSame($existingCalendar, $result); + $this->assertCount(1, $result->VEVENT->ATTENDEE); + $this->assertEquals('NEEDS-ACTION', $result->VEVENT->ATTENDEE[0]['PARTSTAT']->getValue()); + $this->assertTrue(isset($result->VEVENT->ATTENDEE[0]['RSVP'])); + } + + public function testProcessMessageReplyDisallowsInvitationForwardingForGeneratedRecurringInstance(): void { + $existingCalendar = clone $this->vCalendar2a; + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE'); + $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = 'mailto:attendee2@example.org'; + $reply->senderName = 'Attendee Two'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + $replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $replyEvent->add('ATTENDEE', $reply->sender, [ + 'PARTSTAT' => 'ACCEPTED', + ]); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertSame($existingCalendar, $result); + $this->assertCount(1, $result->VEVENT); + $this->assertCount(1, $result->VEVENT->ATTENDEE); + $this->assertEquals('mailto:attendee1@example.org', $result->VEVENT->ATTENDEE[0]->getValue()); + } + + public function testProcessMessageReplyUpdatesExistingAttendeeForGeneratedRecurringInstanceWhenInvitationForwardingDisabled(): void { + $existingCalendar = clone $this->vCalendar2a; + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE'); + $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = 'mailto:attendee1@example.org'; + $reply->senderName = 'Attendee One'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + $replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $replyEvent->add('ATTENDEE', $reply->sender, [ + 'PARTSTAT' => 'ACCEPTED', + ]); + $replyEvent->add('REQUEST-STATUS', '2.0;Success'); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertSame($existingCalendar, $result); + $this->assertCount(2, $result->VEVENT); + $this->assertEquals('20240715T080000', $result->VEVENT[1]->{'RECURRENCE-ID'}->getValue()); + $this->assertCount(1, $result->VEVENT[1]->ATTENDEE); + $this->assertEquals('mailto:attendee1@example.org', $result->VEVENT[1]->ATTENDEE[0]->getValue()); + $this->assertEquals('ACCEPTED', $result->VEVENT[1]->ATTENDEE[0]['PARTSTAT']->getValue()); + $this->assertEquals('2.0', $result->VEVENT[1]->ATTENDEE[0]['SCHEDULE-STATUS']->getValue()); + $this->assertFalse(isset($result->VEVENT[1]->ATTENDEE[0]['RSVP'])); + } + + public function testProcessMessageReplyAllowsInvitationForwardingForDetachedRecurringExceptionWhenMasterDisallows(): void { + $existingCalendar = clone $this->vCalendar2a; + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE'); + $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); + /** @var \Sabre\VObject\Component\VEvent $detachedInstance */ + $detachedInstance = $existingCalendar->add('VEVENT', []); + $detachedInstance->add('UID', $existingCalendar->VEVENT->UID->getValue()); + $detachedInstance->add('DTSTAMP', '20240701T000000Z'); + $detachedInstance->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $detachedInstance->add('DTSTART', '20240715T080000', ['TZID' => 'America/Toronto']); + $detachedInstance->add('DTEND', '20240715T090000', ['TZID' => 'America/Toronto']); + $detachedInstance->add('SUMMARY', 'Detached Test Event'); + $detachedInstance->add('ORGANIZER', 'mailto:organizer@example.org', ['CN' => 'Organizer']); + $detachedInstance->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'TRUE'); + $detachedInstance->add('ATTENDEE', 'mailto:attendee1@example.org', [ + 'CN' => 'Attendee One', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE', + ]); + + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = 'mailto:attendee2@example.org'; + $reply->senderName = 'Attendee Two'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + $replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $replyEvent->add('ATTENDEE', $reply->sender, [ + 'PARTSTAT' => 'ACCEPTED', + ]); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertSame($existingCalendar, $result); + $this->assertCount(2, $result->VEVENT); + $this->assertCount(2, $result->VEVENT[1]->ATTENDEE); + $this->assertEquals('mailto:attendee2@example.org', $result->VEVENT[1]->ATTENDEE[1]->getValue()); + } + + public function testProcessMessageReplyDisallowsInvitationForwardingForDetachedRecurringExceptionWhenMasterAllows(): void { + $existingCalendar = clone $this->vCalendar2a; + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'TRUE'); + $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); + /** @var \Sabre\VObject\Component\VEvent $detachedInstance */ + $detachedInstance = $existingCalendar->add('VEVENT', []); + $detachedInstance->add('UID', $existingCalendar->VEVENT->UID->getValue()); + $detachedInstance->add('DTSTAMP', '20240701T000000Z'); + $detachedInstance->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $detachedInstance->add('DTSTART', '20240715T080000', ['TZID' => 'America/Toronto']); + $detachedInstance->add('DTEND', '20240715T090000', ['TZID' => 'America/Toronto']); + $detachedInstance->add('SUMMARY', 'Detached Test Event'); + $detachedInstance->add('ORGANIZER', 'mailto:organizer@example.org', ['CN' => 'Organizer']); + $detachedInstance->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE'); + $detachedInstance->add('ATTENDEE', 'mailto:attendee1@example.org', [ + 'CN' => 'Attendee One', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE', + ]); + + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = 'mailto:attendee2@example.org'; + $reply->senderName = 'Attendee Two'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + $replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $replyEvent->add('ATTENDEE', $reply->sender, [ + 'PARTSTAT' => 'ACCEPTED', + ]); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertSame($existingCalendar, $result); + $this->assertCount(2, $result->VEVENT); + $this->assertCount(1, $result->VEVENT[1]->ATTENDEE); + $this->assertEquals('mailto:attendee1@example.org', $result->VEVENT[1]->ATTENDEE[0]->getValue()); + } + + public function testProcessMessageReplyAllowsInvitationForwardingForGeneratedRecurringInstance(): void { + $existingCalendar = clone $this->vCalendar2a; + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'TRUE'); + $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = 'mailto:attendee2@example.org'; + $reply->senderName = 'Attendee Two'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + $replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $replyEvent->add('ATTENDEE', $reply->sender, [ + 'PARTSTAT' => 'ACCEPTED', + ]); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertSame($existingCalendar, $result); + $this->assertCount(2, $result->VEVENT); + $this->assertEquals('20240715T080000', $result->VEVENT[1]->{'RECURRENCE-ID'}->getValue()); + $this->assertFalse(isset($result->VEVENT[1]->RRULE)); + $this->assertCount(2, $result->VEVENT[1]->ATTENDEE); + $this->assertEquals('mailto:attendee2@example.org', $result->VEVENT[1]->ATTENDEE[1]->getValue()); + $this->assertEquals('ACCEPTED', $result->VEVENT[1]->ATTENDEE[1]['PARTSTAT']->getValue()); + $this->assertEquals('Attendee Two', $result->VEVENT[1]->ATTENDEE[1]['CN']->getValue()); + } + + public function testProcessMessageReplyAllowsInvitationForwardingByDefaultForGeneratedRecurringInstance(): void { + $existingCalendar = clone $this->vCalendar2a; + $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = 'mailto:attendee2@example.org'; + $reply->senderName = 'Attendee Two'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + $replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $replyEvent->add('ATTENDEE', $reply->sender, [ + 'PARTSTAT' => 'ACCEPTED', + ]); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertSame($existingCalendar, $result); + $this->assertCount(2, $result->VEVENT); + $this->assertEquals('20240715T080000', $result->VEVENT[1]->{'RECURRENCE-ID'}->getValue()); + $this->assertFalse(isset($result->VEVENT[1]->RRULE)); + $this->assertCount(2, $result->VEVENT[1]->ATTENDEE); + $this->assertEquals('mailto:attendee2@example.org', $result->VEVENT[1]->ATTENDEE[1]->getValue()); + $this->assertEquals('ACCEPTED', $result->VEVENT[1]->ATTENDEE[1]['PARTSTAT']->getValue()); + $this->assertEquals('Attendee Two', $result->VEVENT[1]->ATTENDEE[1]['CN']->getValue()); + } + }