Skip to content

Commit 3d8bfef

Browse files
committed
fix(dav): keep cancelled occurrence in iTip REQUEST so attendees keep it cancelled
When an organizer cancels a single occurrence of a recurring event, the broker emits a per-instance CANCEL plus a REQUEST for the attendee's remaining instances. The REQUEST omitted the cancelled instance, but processMessageRequest replaces all components of the attendee's stored object, so it dropped the CANCELLED override the CANCEL had just added and the occurrence reappeared as a normal event on the attendee's calendar. Keep the cancelled instance in the REQUEST so the override survives the component replace. Two adjustments keep the pipeline coherent with the kept override: - IMipPlugin no longer builds an email from a newly cancelled override, which sent a second "Invitation:" email (with response buttons) for the occurrence the CANCEL email had just cancelled. Cancelling a previously modified occurrence produces no CANCEL message, so that override keeps its REQUEST email. - TipBroker derives the attendee-side event status from the master instance; the aggregate status (last component wins) would treat the attendee copy as a cancelled event once it carries the override, muting all attendee replies. Assisted-by: ClaudeCode:claude-opus-4-7 Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Nico Donath <ndo84bw@gmx.de>
1 parent d1fbf3d commit 3d8bfef

4 files changed

Lines changed: 359 additions & 7 deletions

File tree

apps/dav/lib/CalDAV/Schedule/IMipPlugin.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,19 @@ public function schedule(Message $iTipMessage) {
147147
$oldEvents = $this->getVCalendar();
148148

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

354+
/**
355+
* Whether this component is an occurrence override that got cancelled in the
356+
* same write that created it - the only case the broker announces through a
357+
* per-instance CANCEL message. Cancelling a previously modified occurrence
358+
* produces no CANCEL message, so that override must keep its REQUEST email.
359+
* Mirrors the broker's instance tracking, which only registers components
360+
* carrying an ATTENDEE.
361+
*/
362+
private function isNewlyCancelledOccurrence(VEvent $component, ?VCalendar $oldEvents): bool {
363+
if (!isset($component->STATUS) || $component->STATUS->getValue() !== 'CANCELLED'
364+
|| !isset($component->{'RECURRENCE-ID'})) {
365+
return false;
366+
}
367+
if ($oldEvents === null) {
368+
return true;
369+
}
370+
$recurrenceId = $component->{'RECURRENCE-ID'}->getValue();
371+
foreach ($oldEvents->getComponents() as $oldComponent) {
372+
if ($oldComponent instanceof VEvent
373+
&& isset($oldComponent->ATTENDEE, $oldComponent->{'RECURRENCE-ID'})
374+
&& $oldComponent->{'RECURRENCE-ID'}->getValue() === $recurrenceId) {
375+
return false;
376+
}
377+
}
378+
return true;
379+
}
380+
341381
/**
342382
* @return ?VCalendar
343383
*/

apps/dav/lib/CalDAV/TipBroker.php

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,23 @@ protected function allowInvitationForwarding(VEvent $vevent): bool {
262262
return true;
263263
}
264264

265+
/**
266+
* parseEventInfo() aggregates STATUS with the last parsed component winning,
267+
* so a kept cancelled override would mark the whole event as cancelled and
268+
* suppress the attendee's replies for the remaining occurrences; judge the
269+
* event status by the master instance instead.
270+
*
271+
* @return array<int,Message>
272+
*/
273+
#[\Override]
274+
protected function parseEventForAttendee(VCalendar $calendar, array $eventInfo, array $oldEventInfo, $attendee) {
275+
if (isset($eventInfo['instances']['master'])) {
276+
$status = $eventInfo['instances']['master']->STATUS?->getValue();
277+
$eventInfo['status'] = $status === null ? null : strtoupper($status);
278+
}
279+
return parent::parseEventForAttendee($calendar, $eventInfo, $oldEventInfo, $attendee);
280+
}
281+
265282
/**
266283
* This method is used in cases where an event got updated, and we
267284
* potentially need to send emails to attendees to let them know of updates
@@ -319,13 +336,11 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo,
319336
}
320337
return $messages;
321338
}
322-
// detect if a new cancelled instance was created
323-
$cancelledNewInstances = [];
339+
// detect if a new cancelled instance was created and send a CANCEL for it
324340
if (isset($oldEventInfo['instances'])) {
325341
$instancesDelta = array_diff_key($eventInfo['instances'], $oldEventInfo['instances']);
326342
foreach ($instancesDelta as $id => $instance) {
327343
if ($instance->STATUS?->getValue() === 'CANCELLED') {
328-
$cancelledNewInstances[] = $id;
329344
foreach ($eventInfo['attendees'] as $attendee) {
330345
$messages[] = $this->generateMessage(
331346
[$id => $instance], $organizerHref, $organizerName, $attendee, $objectId, $objectType, $objectSequence, 'CANCEL', $template
@@ -366,10 +381,10 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo,
366381
// otherwise any created or modified instances will be sent as REQUEST
367382
$instances = array_intersect_key($eventInfo['instances'], array_flip(array_keys($eventInfo['attendees'][$attendee]['instances'])));
368383

369-
// Remove already-cancelled new instances from REQUEST
370-
if (!empty($cancelledNewInstances)) {
371-
$instances = array_diff_key($instances, array_flip($cancelledNewInstances));
372-
}
384+
// Keep newly cancelled instances IN the REQUEST. processMessageRequest replaces all
385+
// components of the attendee's stored object with the ones from the message, so a
386+
// REQUEST that omitted the cancelled instance would drop the CANCELLED override that
387+
// the accompanying CANCEL added and the occurrence would reappear as a normal event.
373388

374389
// Skip if no instances left to send
375390
if (empty($instances)) {

apps/dav/tests/unit/CalDAV/Schedule/IMipPluginTest.php

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,4 +1237,271 @@ public function testExternalAttendeesDisabledForSystemUser(): void {
12371237
$this->plugin->schedule($message);
12381238
$this->assertEquals('1.1', $message->getScheduleStatus());
12391239
}
1240+
1241+
public function testRequestWithOnlyCancelledOccurrenceSendsNoEmail(): void {
1242+
$message = new Message();
1243+
$message->method = 'REQUEST';
1244+
$newVCalendar = new VCalendar();
1245+
$cancelledOverride = new VEvent($newVCalendar, 'one', [
1246+
'UID' => 'uid-1234',
1247+
'RECURRENCE-ID' => new \DateTime('2016-01-08 00:00:00'),
1248+
'SEQUENCE' => 1,
1249+
'STATUS' => 'CANCELLED',
1250+
'SUMMARY' => 'Fellowship meeting',
1251+
'DTSTART' => new \DateTime('2016-01-08 00:00:00'),
1252+
]);
1253+
$cancelledOverride->add('ORGANIZER', 'mailto:gandalf@wiz.ard');
1254+
$cancelledOverride->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE']);
1255+
$message->message = $newVCalendar;
1256+
$message->sender = 'mailto:gandalf@wiz.ard';
1257+
$message->senderName = 'Mr. Wizard';
1258+
$message->recipient = 'mailto:' . 'frodo@hobb.it';
1259+
$message->significantChange = true;
1260+
// stored old copy: the series without the override, so the cancelled
1261+
// override is newly created in this write
1262+
$oldVCalendar = new VCalendar();
1263+
$oldVCalendar->add(new VEvent($oldVCalendar, 'one', [
1264+
'UID' => 'uid-1234',
1265+
'SEQUENCE' => 0,
1266+
'SUMMARY' => 'Fellowship meeting',
1267+
'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
1268+
]));
1269+
$this->plugin->setVCalendar($oldVCalendar);
1270+
1271+
$this->service->expects(self::once())
1272+
->method('getLastOccurrence')
1273+
->willReturn(1496912700);
1274+
$this->config->expects(self::once())
1275+
->method('getValueBool')
1276+
->with('dav', 'caldav_external_attendees_disabled', false)
1277+
->willReturn(false);
1278+
// The REQUEST carries only the cancelled override, kept so the attendee's
1279+
// stored copy stays cancelled through Sabre's full component replace. The
1280+
// occurrence cancellation is announced by the accompanying CANCEL message,
1281+
// so this REQUEST must not additionally send an invitation email.
1282+
$this->eventComparisonService->expects(self::once())
1283+
->method('findModified')
1284+
->willReturn(['new' => [$cancelledOverride], 'old' => []]);
1285+
$this->service->expects(self::never())
1286+
->method('getCurrentAttendee');
1287+
$this->service->expects(self::never())
1288+
->method('buildBodyData');
1289+
$this->mailer->expects(self::never())
1290+
->method('send');
1291+
// deliberate suppression, not the "significant but nothing changed" anomaly
1292+
$this->logger->expects(self::never())
1293+
->method('warning');
1294+
1295+
$this->plugin->schedule($message);
1296+
$this->assertEquals('1.0', $message->getScheduleStatus());
1297+
}
1298+
1299+
public function testRequestForCancelledExistingOccurrenceStillSendsEmail(): void {
1300+
$message = new Message();
1301+
$message->method = 'REQUEST';
1302+
$newVCalendar = new VCalendar();
1303+
$cancelledOverride = new VEvent($newVCalendar, 'one', [
1304+
'UID' => 'uid-1234',
1305+
'RECURRENCE-ID' => new \DateTime('2016-01-08 00:00:00'),
1306+
'SEQUENCE' => 2,
1307+
'STATUS' => 'CANCELLED',
1308+
'SUMMARY' => 'Fellowship meeting',
1309+
'DTSTART' => new \DateTime('2016-01-08 00:00:00'),
1310+
]);
1311+
$cancelledOverride->add('ORGANIZER', 'mailto:gandalf@wiz.ard');
1312+
$cancelledOverride->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE']);
1313+
$message->message = $newVCalendar;
1314+
$message->sender = 'mailto:gandalf@wiz.ard';
1315+
$message->senderName = 'Mr. Wizard';
1316+
$message->recipient = 'mailto:' . 'frodo@hobb.it';
1317+
$message->significantChange = true;
1318+
// stored old copy already contains a live override for this occurrence:
1319+
// no CANCEL message is generated for it, this REQUEST is the only notice
1320+
$oldVCalendar = new VCalendar();
1321+
$oldVCalendar->add(new VEvent($oldVCalendar, 'one', [
1322+
'UID' => 'uid-1234',
1323+
'SEQUENCE' => 0,
1324+
'SUMMARY' => 'Fellowship meeting',
1325+
'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
1326+
]));
1327+
$oldOverride = new VEvent($oldVCalendar, 'one', [
1328+
'UID' => 'uid-1234',
1329+
'RECURRENCE-ID' => new \DateTime('2016-01-08 00:00:00'),
1330+
'SEQUENCE' => 1,
1331+
'SUMMARY' => 'Fellowship meeting',
1332+
'DTSTART' => new \DateTime('2016-01-08 00:00:00'),
1333+
]);
1334+
$oldOverride->add('ORGANIZER', 'mailto:gandalf@wiz.ard');
1335+
$oldOverride->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE']);
1336+
$oldVCalendar->add($oldOverride);
1337+
$this->plugin->setVCalendar($oldVCalendar);
1338+
$data = [
1339+
'invitee_name' => 'Mr. Wizard',
1340+
'meeting_title' => 'Fellowship meeting',
1341+
'attendee_name' => 'frodo@hobb.it',
1342+
];
1343+
$attendees = $cancelledOverride->select('ATTENDEE');
1344+
$atnd = '';
1345+
foreach ($attendees as $attendee) {
1346+
if (strcasecmp($attendee->getValue(), $message->recipient) === 0) {
1347+
$atnd = $attendee;
1348+
}
1349+
}
1350+
$this->service->expects(self::once())
1351+
->method('getLastOccurrence')
1352+
->willReturn(1496912700);
1353+
$this->config->expects(self::exactly(2))
1354+
->method('getValueBool')
1355+
->willReturnMap([
1356+
['dav', 'caldav_external_attendees_disabled', false, false],
1357+
['core', 'mail_providers_enabled', true, false],
1358+
]);
1359+
$this->eventComparisonService->expects(self::once())
1360+
->method('findModified')
1361+
->willReturn(['new' => [$cancelledOverride], 'old' => [$oldOverride]]);
1362+
$this->service->expects(self::once())
1363+
->method('getCurrentAttendee')
1364+
->with($message)
1365+
->willReturn($atnd);
1366+
$this->service->expects(self::once())
1367+
->method('isRoomOrResource')
1368+
->with($atnd)
1369+
->willReturn(false);
1370+
$this->service->expects(self::once())
1371+
->method('isCircle')
1372+
->with($atnd)
1373+
->willReturn(false);
1374+
$this->service->expects(self::once())
1375+
->method('buildBodyData')
1376+
->with($cancelledOverride, $oldOverride)
1377+
->willReturn($data);
1378+
$this->service->expects(self::once())
1379+
->method('getFrom');
1380+
$this->service->expects(self::once())
1381+
->method('addSubjectAndHeading')
1382+
->with($this->emailTemplate, 'request', 'Mr. Wizard', 'Fellowship meeting', true);
1383+
$this->service->expects(self::once())
1384+
->method('addBulletList')
1385+
->with($this->emailTemplate, $cancelledOverride, $data);
1386+
$this->service->expects(self::once())
1387+
->method('getAttendeeRsvpOrReqForParticipant')
1388+
->willReturn(false);
1389+
$this->mailer->expects(self::once())
1390+
->method('send')
1391+
->willReturn([]);
1392+
$this->plugin->schedule($message);
1393+
$this->assertEquals('1.1', $message->getScheduleStatus());
1394+
}
1395+
1396+
public function testRequestKeepsChangeWhenCancelledOccurrenceIsAlsoPresent(): void {
1397+
$message = new Message();
1398+
$message->method = 'REQUEST';
1399+
$newVCalendar = new VCalendar();
1400+
$newVevent = new VEvent($newVCalendar, 'one', [
1401+
'UID' => 'uid-1234',
1402+
'SEQUENCE' => 1,
1403+
'SUMMARY' => 'Fellowship meeting without (!) Boromir',
1404+
'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
1405+
]);
1406+
$newVevent->add('ORGANIZER', 'mailto:gandalf@wiz.ard');
1407+
$newVevent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']);
1408+
$cancelledOverride = new VEvent($newVCalendar, 'one', [
1409+
'UID' => 'uid-1234',
1410+
'RECURRENCE-ID' => new \DateTime('2016-01-08 00:00:00'),
1411+
'SEQUENCE' => 1,
1412+
'STATUS' => 'CANCELLED',
1413+
'SUMMARY' => 'Fellowship meeting without (!) Boromir',
1414+
'DTSTART' => new \DateTime('2016-01-08 00:00:00'),
1415+
]);
1416+
$cancelledOverride->add('ORGANIZER', 'mailto:gandalf@wiz.ard');
1417+
$cancelledOverride->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE']);
1418+
$message->message = $newVCalendar;
1419+
$message->sender = 'mailto:gandalf@wiz.ard';
1420+
$message->senderName = 'Mr. Wizard';
1421+
$message->recipient = 'mailto:' . 'frodo@hobb.it';
1422+
$message->significantChange = true;
1423+
$oldVCalendar = new VCalendar();
1424+
$oldVEvent = new VEvent($oldVCalendar, 'one', [
1425+
'UID' => 'uid-1234',
1426+
'SEQUENCE' => 0,
1427+
'SUMMARY' => 'Fellowship meeting',
1428+
'DTSTART' => new \DateTime('2016-01-01 00:00:00'),
1429+
]);
1430+
$oldVEvent->add('ORGANIZER', 'mailto:gandalf@wiz.ard');
1431+
$oldVEvent->add('ATTENDEE', 'mailto:' . 'frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']);
1432+
$oldVCalendar->add($oldVEvent);
1433+
$data = [
1434+
'invitee_name' => 'Mr. Wizard',
1435+
'meeting_title' => 'Fellowship meeting without (!) Boromir',
1436+
'attendee_name' => 'frodo@hobb.it',
1437+
];
1438+
$attendees = $newVevent->select('ATTENDEE');
1439+
$atnd = '';
1440+
foreach ($attendees as $attendee) {
1441+
if (strcasecmp($attendee->getValue(), $message->recipient) === 0) {
1442+
$atnd = $attendee;
1443+
}
1444+
}
1445+
$this->plugin->setVCalendar($oldVCalendar);
1446+
$this->service->expects(self::once())
1447+
->method('getLastOccurrence')
1448+
->willReturn(1496912700);
1449+
$this->config->expects(self::exactly(2))
1450+
->method('getValueBool')
1451+
->willReturnMap([
1452+
['dav', 'caldav_external_attendees_disabled', false, false],
1453+
['core', 'mail_providers_enabled', true, false],
1454+
]);
1455+
// The cancelled override rides along in the REQUEST but must not become the
1456+
// event the email describes: it is dropped, leaving the real change.
1457+
$this->eventComparisonService->expects(self::once())
1458+
->method('findModified')
1459+
->willReturn(['new' => [$newVevent, $cancelledOverride], 'old' => [$oldVEvent]]);
1460+
$this->service->expects(self::once())
1461+
->method('getCurrentAttendee')
1462+
->with($message)
1463+
->willReturn($atnd);
1464+
$this->service->expects(self::once())
1465+
->method('isRoomOrResource')
1466+
->with($atnd)
1467+
->willReturn(false);
1468+
$this->service->expects(self::once())
1469+
->method('isCircle')
1470+
->with($atnd)
1471+
->willReturn(false);
1472+
$this->service->expects(self::once())
1473+
->method('buildBodyData')
1474+
->with($newVevent, $oldVEvent)
1475+
->willReturn($data);
1476+
$this->service->expects(self::once())
1477+
->method('getFrom');
1478+
$this->service->expects(self::once())
1479+
->method('addSubjectAndHeading')
1480+
->with($this->emailTemplate, 'request', 'Mr. Wizard', 'Fellowship meeting without (!) Boromir', true);
1481+
$this->service->expects(self::once())
1482+
->method('addBulletList')
1483+
->with($this->emailTemplate, $newVevent, $data);
1484+
$this->service->expects(self::once())
1485+
->method('getAttendeeRsvpOrReqForParticipant')
1486+
->willReturn(true);
1487+
$this->config->expects(self::once())
1488+
->method('getValueString')
1489+
->with('dav', 'invitation_link_recipients', 'yes')
1490+
->willReturn('yes');
1491+
$this->service->expects(self::once())
1492+
->method('createInvitationToken')
1493+
->with($message, $newVevent, 1496912700)
1494+
->willReturn('token');
1495+
$this->service->expects(self::once())
1496+
->method('addResponseButtons')
1497+
->with($this->emailTemplate, 'token');
1498+
$this->service->expects(self::once())
1499+
->method('addMoreOptionsButton')
1500+
->with($this->emailTemplate, 'token');
1501+
$this->mailer->expects(self::once())
1502+
->method('send')
1503+
->willReturn([]);
1504+
$this->plugin->schedule($message);
1505+
$this->assertEquals('1.1', $message->getScheduleStatus());
1506+
}
12401507
}

0 commit comments

Comments
 (0)