Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/dav/lib/CalDAV/Schedule/IMipPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,19 @@ public function schedule(Message $iTipMessage) {
$oldEvents = $this->getVCalendar();

$modified = $this->eventComparisonService->findModified($newEvents, $oldEvents);
// A newly cancelled occurrence is announced through the separate CANCEL
// message; it rides along in the REQUEST only so the attendee's stored copy
// stays cancelled and must not produce an invitation email of its own.
if (strcasecmp($iTipMessage->method, self::METHOD_REQUEST) === 0 && !empty($modified['new'])) {
$modified['new'] = array_values(array_filter(
$modified['new'],
fn (VEvent $component): bool => !$this->isNewlyCancelledOccurrence($component, $oldEvents),
));
if (empty($modified['new'])) {
$iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email';
return;
}
}
/** @var VEvent $vEvent */
$vEvent = array_pop($modified['new']);
/** @var VEvent $oldVevent */
Expand Down Expand Up @@ -338,6 +351,33 @@ public function schedule(Message $iTipMessage) {
}
}

/**
* Whether this component is an occurrence override that got cancelled in the
* same write that created it - the only case the broker announces through a
* per-instance CANCEL message. Cancelling a previously modified occurrence
* produces no CANCEL message, so that override must keep its REQUEST email.
* Mirrors the broker's instance tracking, which only registers components
* carrying an ATTENDEE.
*/
private function isNewlyCancelledOccurrence(VEvent $component, ?VCalendar $oldEvents): bool {
if (!isset($component->STATUS) || $component->STATUS->getValue() !== 'CANCELLED'
|| !isset($component->{'RECURRENCE-ID'})) {
return false;
}
if ($oldEvents === null) {
return true;
}
$recurrenceId = $component->{'RECURRENCE-ID'}->getValue();
foreach ($oldEvents->getComponents() as $oldComponent) {
if ($oldComponent instanceof VEvent
&& isset($oldComponent->ATTENDEE, $oldComponent->{'RECURRENCE-ID'})
&& $oldComponent->{'RECURRENCE-ID'}->getValue() === $recurrenceId) {
return false;
}
}
return true;
}

/**
* @return ?VCalendar
*/
Expand Down
29 changes: 22 additions & 7 deletions apps/dav/lib/CalDAV/TipBroker.php
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,23 @@ protected function allowInvitationForwarding(VEvent $vevent): bool {
return true;
}

/**
* parseEventInfo() aggregates STATUS with the last parsed component winning,
* so a kept cancelled override would mark the whole event as cancelled and
* suppress the attendee's replies for the remaining occurrences; judge the
* event status by the master instance instead.
*
* @return array<int,Message>
*/
#[\Override]
protected function parseEventForAttendee(VCalendar $calendar, array $eventInfo, array $oldEventInfo, $attendee) {
if (isset($eventInfo['instances']['master'])) {
$status = $eventInfo['instances']['master']->STATUS?->getValue();
$eventInfo['status'] = $status === null ? null : strtoupper($status);
}
return parent::parseEventForAttendee($calendar, $eventInfo, $oldEventInfo, $attendee);
}

/**
* 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
Expand Down Expand Up @@ -319,13 +336,11 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo,
}
return $messages;
}
// detect if a new cancelled instance was created
$cancelledNewInstances = [];
// detect if a new cancelled instance was created and send a CANCEL for it
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
Expand Down Expand Up @@ -366,10 +381,10 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo,
// 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));
}
// Keep newly cancelled instances IN the REQUEST. processMessageRequest replaces all
// components of the attendee's stored object with the ones from the message, so a
// REQUEST that omitted the cancelled instance would drop the CANCELLED override that
// the accompanying CANCEL added and the occurrence would reappear as a normal event.

// Skip if no instances left to send
if (empty($instances)) {
Expand Down
267 changes: 267 additions & 0 deletions apps/dav/tests/unit/CalDAV/Schedule/IMipPluginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1237,4 +1237,271 @@ public function testExternalAttendeesDisabledForSystemUser(): void {
$this->plugin->schedule($message);
$this->assertEquals('1.1', $message->getScheduleStatus());
}

public function testRequestWithOnlyCancelledOccurrenceSendsNoEmail(): void {
$message = new Message();
$message->method = 'REQUEST';
$newVCalendar = new VCalendar();
$cancelledOverride = new VEvent($newVCalendar, 'one', [
'UID' => 'uid-1234',
'RECURRENCE-ID' => new \DateTime('2016-01-08 00:00:00'),
'SEQUENCE' => 1,
'STATUS' => 'CANCELLED',
'SUMMARY' => 'Fellowship meeting',
'DTSTART' => new \DateTime('2016-01-08 00:00:00'),
]);
$cancelledOverride->add('ORGANIZER', 'mailto:gandalf@wiz.ard');
$cancelledOverride->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE']);
$message->message = $newVCalendar;
$message->sender = 'mailto:gandalf@wiz.ard';
$message->senderName = 'Mr. Wizard';
$message->recipient = 'mailto:' . 'frodo@hobb.it';
$message->significantChange = true;
// stored old copy: the series without the override, so the cancelled
// override is newly created in this write
$oldVCalendar = new VCalendar();
$oldVCalendar->add(new VEvent($oldVCalendar, 'one', [
'UID' => 'uid-1234',
'SEQUENCE' => 0,
'SUMMARY' => 'Fellowship meeting',
'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
]));
$this->plugin->setVCalendar($oldVCalendar);

$this->service->expects(self::once())
->method('getLastOccurrence')
->willReturn(1496912700);
$this->config->expects(self::once())
->method('getValueBool')
->with('dav', 'caldav_external_attendees_disabled', false)
->willReturn(false);
// The REQUEST carries only the cancelled override, kept so the attendee's
// stored copy stays cancelled through Sabre's full component replace. The
// occurrence cancellation is announced by the accompanying CANCEL message,
// so this REQUEST must not additionally send an invitation email.
$this->eventComparisonService->expects(self::once())
->method('findModified')
->willReturn(['new' => [$cancelledOverride], 'old' => []]);
$this->service->expects(self::never())
->method('getCurrentAttendee');
$this->service->expects(self::never())
->method('buildBodyData');
$this->mailer->expects(self::never())
->method('send');
// deliberate suppression, not the "significant but nothing changed" anomaly
$this->logger->expects(self::never())
->method('warning');

$this->plugin->schedule($message);
$this->assertEquals('1.0', $message->getScheduleStatus());
}

public function testRequestForCancelledExistingOccurrenceStillSendsEmail(): void {
$message = new Message();
$message->method = 'REQUEST';
$newVCalendar = new VCalendar();
$cancelledOverride = new VEvent($newVCalendar, 'one', [
'UID' => 'uid-1234',
'RECURRENCE-ID' => new \DateTime('2016-01-08 00:00:00'),
'SEQUENCE' => 2,
'STATUS' => 'CANCELLED',
'SUMMARY' => 'Fellowship meeting',
'DTSTART' => new \DateTime('2016-01-08 00:00:00'),
]);
$cancelledOverride->add('ORGANIZER', 'mailto:gandalf@wiz.ard');
$cancelledOverride->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE']);
$message->message = $newVCalendar;
$message->sender = 'mailto:gandalf@wiz.ard';
$message->senderName = 'Mr. Wizard';
$message->recipient = 'mailto:' . 'frodo@hobb.it';
$message->significantChange = true;
// stored old copy already contains a live override for this occurrence:
// no CANCEL message is generated for it, this REQUEST is the only notice
$oldVCalendar = new VCalendar();
$oldVCalendar->add(new VEvent($oldVCalendar, 'one', [
'UID' => 'uid-1234',
'SEQUENCE' => 0,
'SUMMARY' => 'Fellowship meeting',
'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
]));
$oldOverride = new VEvent($oldVCalendar, 'one', [
'UID' => 'uid-1234',
'RECURRENCE-ID' => new \DateTime('2016-01-08 00:00:00'),
'SEQUENCE' => 1,
'SUMMARY' => 'Fellowship meeting',
'DTSTART' => new \DateTime('2016-01-08 00:00:00'),
]);
$oldOverride->add('ORGANIZER', 'mailto:gandalf@wiz.ard');
$oldOverride->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE']);
$oldVCalendar->add($oldOverride);
$this->plugin->setVCalendar($oldVCalendar);
$data = [
'invitee_name' => 'Mr. Wizard',
'meeting_title' => 'Fellowship meeting',
'attendee_name' => 'frodo@hobb.it',
];
$attendees = $cancelledOverride->select('ATTENDEE');
$atnd = '';
foreach ($attendees as $attendee) {
if (strcasecmp($attendee->getValue(), $message->recipient) === 0) {
$atnd = $attendee;
}
}
$this->service->expects(self::once())
->method('getLastOccurrence')
->willReturn(1496912700);
$this->config->expects(self::exactly(2))
->method('getValueBool')
->willReturnMap([
['dav', 'caldav_external_attendees_disabled', false, false],
['core', 'mail_providers_enabled', true, false],
]);
$this->eventComparisonService->expects(self::once())
->method('findModified')
->willReturn(['new' => [$cancelledOverride], 'old' => [$oldOverride]]);
$this->service->expects(self::once())
->method('getCurrentAttendee')
->with($message)
->willReturn($atnd);
$this->service->expects(self::once())
->method('isRoomOrResource')
->with($atnd)
->willReturn(false);
$this->service->expects(self::once())
->method('isCircle')
->with($atnd)
->willReturn(false);
$this->service->expects(self::once())
->method('buildBodyData')
->with($cancelledOverride, $oldOverride)
->willReturn($data);
$this->service->expects(self::once())
->method('getFrom');
$this->service->expects(self::once())
->method('addSubjectAndHeading')
->with($this->emailTemplate, 'request', 'Mr. Wizard', 'Fellowship meeting', true);
$this->service->expects(self::once())
->method('addBulletList')
->with($this->emailTemplate, $cancelledOverride, $data);
$this->service->expects(self::once())
->method('getAttendeeRsvpOrReqForParticipant')
->willReturn(false);
$this->mailer->expects(self::once())
->method('send')
->willReturn([]);
$this->plugin->schedule($message);
$this->assertEquals('1.1', $message->getScheduleStatus());
}

public function testRequestKeepsChangeWhenCancelledOccurrenceIsAlsoPresent(): void {
$message = new Message();
$message->method = 'REQUEST';
$newVCalendar = new VCalendar();
$newVevent = new VEvent($newVCalendar, 'one', [
'UID' => 'uid-1234',
'SEQUENCE' => 1,
'SUMMARY' => 'Fellowship meeting without (!) Boromir',
'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
]);
$newVevent->add('ORGANIZER', 'mailto:gandalf@wiz.ard');
$newVevent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']);
$cancelledOverride = new VEvent($newVCalendar, 'one', [
'UID' => 'uid-1234',
'RECURRENCE-ID' => new \DateTime('2016-01-08 00:00:00'),
'SEQUENCE' => 1,
'STATUS' => 'CANCELLED',
'SUMMARY' => 'Fellowship meeting without (!) Boromir',
'DTSTART' => new \DateTime('2016-01-08 00:00:00'),
]);
$cancelledOverride->add('ORGANIZER', 'mailto:gandalf@wiz.ard');
$cancelledOverride->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE']);
$message->message = $newVCalendar;
$message->sender = 'mailto:gandalf@wiz.ard';
$message->senderName = 'Mr. Wizard';
$message->recipient = 'mailto:' . 'frodo@hobb.it';
$message->significantChange = true;
$oldVCalendar = new VCalendar();
$oldVEvent = new VEvent($oldVCalendar, 'one', [
'UID' => 'uid-1234',
'SEQUENCE' => 0,
'SUMMARY' => 'Fellowship meeting',
'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
]);
$oldVEvent->add('ORGANIZER', 'mailto:gandalf@wiz.ard');
$oldVEvent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']);
$oldVCalendar->add($oldVEvent);
$data = [
'invitee_name' => 'Mr. Wizard',
'meeting_title' => 'Fellowship meeting without (!) Boromir',
'attendee_name' => 'frodo@hobb.it',
];
$attendees = $newVevent->select('ATTENDEE');
$atnd = '';
foreach ($attendees as $attendee) {
if (strcasecmp($attendee->getValue(), $message->recipient) === 0) {
$atnd = $attendee;
}
}
$this->plugin->setVCalendar($oldVCalendar);
$this->service->expects(self::once())
->method('getLastOccurrence')
->willReturn(1496912700);
$this->config->expects(self::exactly(2))
->method('getValueBool')
->willReturnMap([
['dav', 'caldav_external_attendees_disabled', false, false],
['core', 'mail_providers_enabled', true, false],
]);
// The cancelled override rides along in the REQUEST but must not become the
// event the email describes: it is dropped, leaving the real change.
$this->eventComparisonService->expects(self::once())
->method('findModified')
->willReturn(['new' => [$newVevent, $cancelledOverride], 'old' => [$oldVEvent]]);
$this->service->expects(self::once())
->method('getCurrentAttendee')
->with($message)
->willReturn($atnd);
$this->service->expects(self::once())
->method('isRoomOrResource')
->with($atnd)
->willReturn(false);
$this->service->expects(self::once())
->method('isCircle')
->with($atnd)
->willReturn(false);
$this->service->expects(self::once())
->method('buildBodyData')
->with($newVevent, $oldVEvent)
->willReturn($data);
$this->service->expects(self::once())
->method('getFrom');
$this->service->expects(self::once())
->method('addSubjectAndHeading')
->with($this->emailTemplate, 'request', 'Mr. Wizard', 'Fellowship meeting without (!) Boromir', true);
$this->service->expects(self::once())
->method('addBulletList')
->with($this->emailTemplate, $newVevent, $data);
$this->service->expects(self::once())
->method('getAttendeeRsvpOrReqForParticipant')
->willReturn(true);
$this->config->expects(self::once())
->method('getValueString')
->with('dav', 'invitation_link_recipients', 'yes')
->willReturn('yes');
$this->service->expects(self::once())
->method('createInvitationToken')
->with($message, $newVevent, 1496912700)
->willReturn('token');
$this->service->expects(self::once())
->method('addResponseButtons')
->with($this->emailTemplate, 'token');
$this->service->expects(self::once())
->method('addMoreOptionsButton')
->with($this->emailTemplate, 'token');
$this->mailer->expects(self::once())
->method('send')
->willReturn([]);
$this->plugin->schedule($message);
$this->assertEquals('1.1', $message->getScheduleStatus());
}
}
Loading
Loading