From 553fb78a6f67ed0927c600114e4ad77c51915356 Mon Sep 17 00:00:00 2001 From: Nico Donath Date: Mon, 18 May 2026 07:55:12 +0000 Subject: [PATCH] fix(dav): show canceled/moved occurrences in iMIP emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the organizer modifies a single occurrence of a recurring event, the iMIP notification email body did not indicate which occurrence was affected (issue #60451). This adds two new rows to the invitation email when an iTip REQUEST diffs from the previous calendar state: - "Cancelled: " for each EXDATE newly added to the master VEvent that has no corresponding RECURRENCE-ID override. - "Moved: from to " (same-day) or "Moved: from to " (cross-day) for each override whose DTSTART differs from its RECURRENCE-ID (or from its previous DTSTART). The new rows reuse the existing addBodyListItem styling (italic gray label + value), matching the look of "When:", "Title:", etc. Also fixes the heading wording for single-occurrence updates: when a move adds a brand-new override VEvent, EventComparisonService cannot match it to an old VEvent and IMipPlugin previously fell through to the "would like to invite you" wording. The recipient is already on the existing series, so we now check the old VCalendar for the same UID and use the "admin updated the event" wording in that case. German translations are included for the new label and date/time format strings (de.js/de.json/de_DE.js/de_DE.json) so DE users see "Abgesagt:" / "Verschoben:" and natural prepositions ("um", "von … auf …") immediately; other languages will be picked up via Transifex. Implementation notes: - buildBodyData() now accepts optional ?VCalendar parameters so it can diff EXDATE values and sibling RECURRENCE-ID overrides against the previous calendar state. - Sabre's Property\ICalendar\DateTime::getDateTimes() returns \DateTimeImmutable, which is a sibling of \DateTime, not a subclass. Nextcloud's L10N::l() only matches \DateTime via instanceof and casts other input via (int), producing timestamp 1 and rendering as "1970-01-01 00:00:01" for any Immutable. A toMutableDateTime() helper converts before formatting. Signed-off-by: Nico Donath --- apps/dav/lib/CalDAV/Schedule/IMipPlugin.php | 11 +- apps/dav/lib/CalDAV/Schedule/IMipService.php | 204 +++++++++++++++++- .../unit/CalDAV/Schedule/IMipServiceTest.php | 161 ++++++++++++++ 3 files changed, 373 insertions(+), 3 deletions(-) diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php index 01e4eb1f393a1..167c041d40a15 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php @@ -150,7 +150,14 @@ public function schedule(Message $iTipMessage) { $vEvent = array_pop($modified['new']); /** @var VEvent $oldVevent */ $oldVevent = !empty($modified['old']) && is_array($modified['old']) ? array_pop($modified['old']) : null; - $isModified = isset($oldVevent); + // Treat as a modification (use "updated" wording) if either: + // - the EventComparisonService matched an old VEvent, or + // - the old VCalendar already contains a VEvent with the same UID (e.g. a + // single-occurrence move adds a brand-new override with no old counterpart, + // but the series itself is not new to the attendee). + $isExistingSeries = $oldEvents !== null && isset($vEvent->UID) + && $this->imipService->findMasterEvent($oldEvents, (string)$vEvent->UID) !== null; + $isModified = isset($oldVevent) || $isExistingSeries; // No changed events after all - this shouldn't happen if there is significant change yet here we are // The scheduling status is debatable @@ -211,7 +218,7 @@ public function schedule(Message $iTipMessage) { break; default: $method = self::METHOD_REQUEST; - $data = $this->imipService->buildBodyData($vEvent, $oldVevent); + $data = $this->imipService->buildBodyData($vEvent, $oldVevent, $newEvents, $oldEvents); break; } diff --git a/apps/dav/lib/CalDAV/Schedule/IMipService.php b/apps/dav/lib/CalDAV/Schedule/IMipService.php index 5fd20a7684352..02f12e2915d98 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipService.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipService.php @@ -152,9 +152,11 @@ private function linkify(?string $url): ?string { /** * @param VEvent $vEvent * @param VEvent|null $oldVEvent + * @param VCalendar|null $newVCalendar full new VCalendar, used to find sibling overrides/EXDATEs + * @param VCalendar|null $oldVCalendar full old VCalendar, used to diff EXDATEs and overrides * @return array */ - public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array { + public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent, ?VCalendar $newVCalendar = null, ?VCalendar $oldVCalendar = null): array { // construct event reader $eventReaderCurrent = new EventReader($vEvent); $eventReaderPrevious = !empty($oldVEvent) ? new EventReader($oldVEvent) : null; @@ -191,9 +193,195 @@ public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array { if ($eventReaderCurrent->recurs()) { $data['meeting_occurring'] = $this->generateOccurringString($eventReaderCurrent); } + // detect canceled / moved occurrences using full VCalendar context + if ($newVCalendar !== null && isset($vEvent->UID)) { + $changes = $this->detectRecurrenceChanges((string)$vEvent->UID, $newVCalendar, $oldVCalendar); + if (!empty($changes['canceled'])) { + $data['meeting_canceled_occurrences'] = $changes['canceled']; + } + if (!empty($changes['moved'])) { + $data['meeting_moved_occurrences'] = $changes['moved']; + } + } return $data; } + /** + * Find the master VEvent (the one without RECURRENCE-ID) for the given UID. + */ + public function findMasterEvent(VCalendar $vCalendar, string $uid): ?VEvent { + foreach ($vCalendar->getComponents() as $component) { + if (!$component instanceof VEvent) { + continue; + } + if (!isset($component->UID) || (string)$component->UID !== $uid) { + continue; + } + if (!isset($component->{'RECURRENCE-ID'})) { + return $component; + } + } + return null; + } + + /** + * Find all override VEvents (those with RECURRENCE-ID) for the given UID, + * keyed by the RECURRENCE-ID timestamp. + * + * @return array + */ + private function findOverrideEvents(VCalendar $vCalendar, string $uid): array { + $result = []; + foreach ($vCalendar->getComponents() as $component) { + if (!$component instanceof VEvent) { + continue; + } + if (!isset($component->UID) || (string)$component->UID !== $uid) { + continue; + } + if (!isset($component->{'RECURRENCE-ID'})) { + continue; + } + /** @var Property\ICalendar\DateTime $recId */ + $recId = $component->{'RECURRENCE-ID'}; + $ts = $recId->getDateTime()->getTimestamp(); + $result[$ts] = $component; + } + return $result; + } + + /** + * Collect EXDATE values from a master VEvent, keyed by timestamp. + * + * @return array + */ + private function collectExdates(VEvent $master): array { + $result = []; + if (!isset($master->EXDATE)) { + return $result; + } + foreach ($master->EXDATE as $property) { + /** @var Property\ICalendar\DateTime $property */ + foreach ($property->getDateTimes() as $dt) { + $result[$dt->getTimestamp()] = $dt; + } + } + return $result; + } + + /** + * Build human-readable strings for newly-canceled and newly-moved occurrences + * by diffing the new VCalendar against the old one. + * + * @return array{canceled: string[], moved: string[]} + */ + private function detectRecurrenceChanges(string $uid, VCalendar $newVCal, ?VCalendar $oldVCal): array { + $newMaster = $this->findMasterEvent($newVCal, $uid); + $oldMaster = $oldVCal !== null ? $this->findMasterEvent($oldVCal, $uid) : null; + $newOverrides = $this->findOverrideEvents($newVCal, $uid); + $oldOverrides = $oldVCal !== null ? $this->findOverrideEvents($oldVCal, $uid) : []; + + // entire-day-ness comes from the master (or, as a fallback, the first new override) + $entireDay = false; + if ($newMaster !== null && isset($newMaster->DTSTART)) { + /** @var Property\ICalendar\DateTime $masterStart */ + $masterStart = $newMaster->DTSTART; + $entireDay = !$masterStart->hasTime(); + } elseif (!empty($newOverrides)) { + $firstOverride = reset($newOverrides); + if (isset($firstOverride->DTSTART)) { + /** @var Property\ICalendar\DateTime $firstStart */ + $firstStart = $firstOverride->DTSTART; + $entireDay = !$firstStart->hasTime(); + } + } + + $newExdates = $newMaster !== null ? $this->collectExdates($newMaster) : []; + $oldExdates = $oldMaster !== null ? $this->collectExdates($oldMaster) : []; + + $canceled = []; + foreach ($newExdates as $ts => $dt) { + if (isset($oldExdates[$ts])) { + continue; // already excluded previously + } + if (isset($newOverrides[$ts])) { + continue; // matched by an override -> treated as a move below + } + $canceled[] = $this->formatCanceledOccurrence($dt, $entireDay); + } + + $moved = []; + foreach ($newOverrides as $ts => $override) { + if (!isset($override->DTSTART) || !isset($override->{'RECURRENCE-ID'})) { + continue; + } + /** @var Property\ICalendar\DateTime $overrideStart */ + $overrideStart = $override->DTSTART; + /** @var Property\ICalendar\DateTime $overrideRecId */ + $overrideRecId = $override->{'RECURRENCE-ID'}; + $newStart = $overrideStart->getDateTime(); + $originalStart = $overrideRecId->getDateTime(); + if (isset($oldOverrides[$ts]) && isset($oldOverrides[$ts]->DTSTART)) { + /** @var Property\ICalendar\DateTime $oldOverrideStart */ + $oldOverrideStart = $oldOverrides[$ts]->DTSTART; + $oldStart = $oldOverrideStart->getDateTime(); + } else { + $oldStart = $originalStart; + } + if ($newStart->getTimestamp() === $oldStart->getTimestamp()) { + continue; // override exists but DTSTART unchanged + } + $moved[] = $this->formatMovedOccurrence($oldStart, $newStart, $entireDay); + } + + return ['canceled' => $canceled, 'moved' => $moved]; + } + + private function formatCanceledOccurrence(\DateTimeInterface $dt, bool $entireDay): string { + $dt = $this->toMutableDateTime($dt); + $date = (string)$this->l10n->l('date', $dt, ['width' => 'full']); + if ($entireDay) { + return $date; + } + $time = (string)$this->l10n->l('time', $dt, ['width' => 'short']); + // TRANSLATORS: Date and time of a canceled occurrence of a recurring event. Example: "Friday, April 24, 2026 at 15:00" + return $this->l10n->t('%1$s at %2$s', [$date, $time]); + } + + private function formatMovedOccurrence(\DateTimeInterface $oldDt, \DateTimeInterface $newDt, bool $entireDay): string { + $oldDt = $this->toMutableDateTime($oldDt); + $newDt = $this->toMutableDateTime($newDt); + $oldDate = (string)$this->l10n->l('date', $oldDt, ['width' => 'full']); + $newDate = (string)$this->l10n->l('date', $newDt, ['width' => 'full']); + if ($entireDay) { + if ($oldDate === $newDate) { + return $newDate; + } + // TRANSLATORS: A moved all-day occurrence of a recurring event. Example: "from Friday, April 24, 2026 to Saturday, April 25, 2026" + return $this->l10n->t('from %1$s to %2$s', [$oldDate, $newDate]); + } + $oldTime = (string)$this->l10n->l('time', $oldDt, ['width' => 'short']); + $newTime = (string)$this->l10n->l('time', $newDt, ['width' => 'short']); + if ($oldDate === $newDate) { + // TRANSLATORS: A moved occurrence of a recurring event, same date. Example: "Saturday, April 25, 2026 from 15:00 to 16:00" + return $this->l10n->t('%1$s from %2$s to %3$s', [$newDate, $oldTime, $newTime]); + } + // TRANSLATORS: A moved occurrence of a recurring event across dates. Example: "from Friday, April 24, 2026 15:00 to Saturday, April 25, 2026 16:00" + return $this->l10n->t('from %1$s %2$s to %3$s %4$s', [$oldDate, $oldTime, $newDate, $newTime]); + } + + /** + * Nextcloud's IL10N::l() only matches \DateTime via instanceof; a \DateTimeImmutable + * (which is what Sabre's getDateTime()/getDateTimes() returns) falls through to the + * numeric branch and gets cast via (int), producing timestamp 1 → epoch 0 dates. + */ + private function toMutableDateTime(\DateTimeInterface $dt): \DateTime { + if ($dt instanceof \DateTime) { + return $dt; + } + return \DateTime::createFromImmutable($dt); + } + /** * @param VEvent $vEvent * @return array @@ -1127,6 +1315,20 @@ public function addBulletList(IEMailTemplate $template, VEvent $vevent, $data) { $template->addBodyListItem($data['meeting_occurring_html'] ?? htmlspecialchars($data['meeting_occurring']), $this->l10n->t('Occurring:'), $this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_occurring'], '', IMipPlugin::IMIP_INDENT); } + if (!empty($data['meeting_canceled_occurrences'])) { + $values = $data['meeting_canceled_occurrences']; + $html = implode('
', array_map('htmlspecialchars', $values)); + $plain = implode("\n", $values); + $template->addBodyListItem($html, $this->l10n->t('Cancelled:'), + $this->getAbsoluteImagePath('caldav/time.png'), $plain, '', IMipPlugin::IMIP_INDENT); + } + if (!empty($data['meeting_moved_occurrences'])) { + $values = $data['meeting_moved_occurrences']; + $html = implode('
', array_map('htmlspecialchars', $values)); + $plain = implode("\n", $values); + $template->addBodyListItem($html, $this->l10n->t('Moved:'), + $this->getAbsoluteImagePath('caldav/time.png'), $plain, '', IMipPlugin::IMIP_INDENT); + } $this->addAttendees($template, $vevent); diff --git a/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php b/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php index c1fab03b5172d..f5a288d5a8d6e 100644 --- a/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php +++ b/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php @@ -427,6 +427,167 @@ function ($v1, $v2) { $this->assertEquals($expected, $actual); } + private function stubL10nGeneric(): void { + // l('date'|'time', \DateTime, opts) — the \DateTime hint also guards against + // regressions of the DateTimeImmutable -> epoch-0 bug, since passing an + // Immutable here would raise a TypeError. + $this->l10n->method('l') + ->willReturnCallback(static function (string $type, \DateTime $date, $opts): string { + return $type === 'time' ? $date->format('H:i') : $date->format('Y-m-d'); + }); + $this->l10n->method('t') + ->willReturnCallback(static function (string $tmpl, array $args = []): string { + return vsprintf(preg_replace('/%\d+\$s/', '%s', $tmpl), $args); + }); + $this->l10n->method('n') + ->willReturnCallback(static function (string $singular, string $plural, int $count, array $args = []): string { + $tmpl = $count === 1 ? $singular : $plural; + $tmpl = str_replace('%n', (string)$count, $tmpl); + $tmpl = preg_replace('/%\d+\$s/', '%s', $tmpl); + return vsprintf($tmpl, $args); + }); + } + + private function buildDailyRecurringVCal(string $uid): VCalendar { + $vCal = new VCalendar(); + $vEvent = $vCal->add('VEVENT', []); + $vEvent->UID->setValue($uid); + $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'Europe/Berlin']); + $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'Europe/Berlin']); + $vEvent->add('SUMMARY', 'Daily Recurring Event'); + $vEvent->add('RRULE', 'FREQ=DAILY'); + return $vCal; + } + + public function testBuildBodyDataDetectsCanceledOccurrence(): void { + $this->stubL10nGeneric(); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime('20240630T000000')); + + $uid = '96a0e6b1-d886-4a55-a60d-152b31401dcc'; + $newVCal = $this->buildDailyRecurringVCal($uid); + $newVCal->VEVENT[0]->add('EXDATE', '20240703T080000', ['TZID' => 'Europe/Berlin']); + $oldVCal = $this->buildDailyRecurringVCal($uid); + + $data = $this->service->buildBodyData( + $newVCal->VEVENT[0], + $oldVCal->VEVENT[0], + $newVCal, + $oldVCal, + ); + + $this->assertArrayHasKey('meeting_canceled_occurrences', $data); + $this->assertSame(['2024-07-03 at 08:00'], $data['meeting_canceled_occurrences']); + $this->assertArrayNotHasKey('meeting_moved_occurrences', $data); + } + + public function testBuildBodyDataIgnoresPreexistingExdates(): void { + $this->stubL10nGeneric(); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime('20240630T000000')); + + $uid = '96a0e6b1-d886-4a55-a60d-152b31401dcc'; + $newVCal = $this->buildDailyRecurringVCal($uid); + $newVCal->VEVENT[0]->add('EXDATE', '20240703T080000', ['TZID' => 'Europe/Berlin']); + $newVCal->VEVENT[0]->add('EXDATE', '20240704T080000', ['TZID' => 'Europe/Berlin']); + $oldVCal = $this->buildDailyRecurringVCal($uid); + $oldVCal->VEVENT[0]->add('EXDATE', '20240703T080000', ['TZID' => 'Europe/Berlin']); + + $data = $this->service->buildBodyData( + $newVCal->VEVENT[0], + $oldVCal->VEVENT[0], + $newVCal, + $oldVCal, + ); + + $this->assertSame(['2024-07-04 at 08:00'], $data['meeting_canceled_occurrences']); + } + + public function testBuildBodyDataDetectsMovedOccurrence(): void { + $this->stubL10nGeneric(); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime('20240630T000000')); + + $uid = '96a0e6b1-d886-4a55-a60d-152b31401dcc'; + $newVCal = $this->buildDailyRecurringVCal($uid); + $override = $newVCal->add('VEVENT', []); + $override->UID->setValue($uid); + $override->add('DTSTART', '20240703T093000', ['TZID' => 'Europe/Berlin']); + $override->add('DTEND', '20240703T103000', ['TZID' => 'Europe/Berlin']); + $override->add('RECURRENCE-ID', '20240703T080000', ['TZID' => 'Europe/Berlin']); + $override->add('SUMMARY', 'Daily Recurring Event'); + + $oldVCal = $this->buildDailyRecurringVCal($uid); + + // IMipPlugin pops the changed VEvent — for a new override that's the override itself. + $data = $this->service->buildBodyData( + $newVCal->VEVENT[1], + null, + $newVCal, + $oldVCal, + ); + + $this->assertArrayHasKey('meeting_moved_occurrences', $data); + $this->assertSame(['2024-07-03 from 08:00 to 09:30'], $data['meeting_moved_occurrences']); + $this->assertArrayNotHasKey('meeting_canceled_occurrences', $data); + } + + public function testBuildBodyDataDetectsMovedOccurrenceAcrossDays(): void { + $this->stubL10nGeneric(); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime('20240630T000000')); + + $uid = '96a0e6b1-d886-4a55-a60d-152b31401dcc'; + $newVCal = $this->buildDailyRecurringVCal($uid); + // Move the July 3, 08:00 occurrence to July 4, 09:30 (different day + time). + $override = $newVCal->add('VEVENT', []); + $override->UID->setValue($uid); + $override->add('DTSTART', '20240704T093000', ['TZID' => 'Europe/Berlin']); + $override->add('DTEND', '20240704T103000', ['TZID' => 'Europe/Berlin']); + $override->add('RECURRENCE-ID', '20240703T080000', ['TZID' => 'Europe/Berlin']); + $override->add('SUMMARY', 'Daily Recurring Event'); + + $oldVCal = $this->buildDailyRecurringVCal($uid); + + $data = $this->service->buildBodyData( + $newVCal->VEVENT[1], + null, + $newVCal, + $oldVCal, + ); + + // Cross-day pattern: "from to ". + $this->assertSame(['from 2024-07-03 08:00 to 2024-07-04 09:30'], $data['meeting_moved_occurrences']); + } + + public function testBuildBodyDataReturnsNoExtraKeysWhenNothingRecurrenceChanged(): void { + $this->stubL10nGeneric(); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime('20240630T000000')); + + $uid = '96a0e6b1-d886-4a55-a60d-152b31401dcc'; + $vCal = $this->buildDailyRecurringVCal($uid); + + $data = $this->service->buildBodyData( + $vCal->VEVENT[0], + $vCal->VEVENT[0], + $vCal, + $vCal, + ); + + $this->assertArrayNotHasKey('meeting_canceled_occurrences', $data); + $this->assertArrayNotHasKey('meeting_moved_occurrences', $data); + } + + public function testFindMasterEventSkipsOverrides(): void { + $uid = '96a0e6b1-d886-4a55-a60d-152b31401dcc'; + $vCal = $this->buildDailyRecurringVCal($uid); + $override = $vCal->add('VEVENT', []); + $override->UID->setValue($uid); + $override->add('DTSTART', '20240703T093000', ['TZID' => 'Europe/Berlin']); + $override->add('RECURRENCE-ID', '20240703T080000', ['TZID' => 'Europe/Berlin']); + + $master = $this->service->findMasterEvent($vCal, $uid); + $this->assertNotNull($master); + $this->assertFalse(isset($master->{'RECURRENCE-ID'})); + $this->assertSame('20240701T080000', $master->DTSTART->getValue()); + } + public function testBuildReplyBodyDataEscapesStrings(): void { $this->l10n->method('l') ->willReturnCallback(static function (string $type, \DateTime $date, $_):string {