diff --git a/lib/private/Calendar/CalendarEventBuilder.php b/lib/private/Calendar/CalendarEventBuilder.php index 818a034911b16..68bff6fff7765 100644 --- a/lib/private/Calendar/CalendarEventBuilder.php +++ b/lib/private/Calendar/CalendarEventBuilder.php @@ -123,6 +123,21 @@ public function toIcs(): string { foreach ($this->attendees as $attendee) { self::addAttendeeToVEvent($vevent, 'ATTENDEE', $attendee); } + + // Prefix TZID values with '/' to reference globally-defined IANA timezone identifiers + // (RFC 5545 §3.2.19). This avoids the need for a VTIMEZONE component: without it, + // iTIP invite emails carry TZID=Europe/Berlin with no accompanying VTIMEZONE block, + // and some clients (e.g. Grommunio) misinterpret the time. + foreach (['DTSTART', 'DTEND'] as $propName) { + $prop = $vevent->$propName; + if ($prop !== null && isset($prop['TZID'])) { + $tzid = (string)$prop['TZID']; + if ($tzid !== '' && !str_starts_with($tzid, '/')) { + $prop['TZID'] = '/' . $tzid; + } + } + } + return $vcalendar->serialize(); } diff --git a/tests/data/ics/event-builder-with-timezone.ics b/tests/data/ics/event-builder-with-timezone.ics new file mode 100644 index 0000000000000..c662b766ac5cf --- /dev/null +++ b/tests/data/ics/event-builder-with-timezone.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Sabre//Sabre VObject 4.5.6//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +UID:event-uid-123 +DTSTAMP:20250105T000000Z +SUMMARY:My event +DTSTART;TZID=/Europe/Berlin:20260511T110000 +DTEND;TZID=/Europe/Berlin:20260511T120000 +STATUS:CONFIRMED +DESCRIPTION:Foo bar baz +ORGANIZER;CN=Organizer;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:organizer@domain + .tld +ATTENDEE;CN=Attendee;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION:mailto:atte + ndee@domain.tld +END:VEVENT +END:VCALENDAR \ No newline at end of file diff --git a/tests/data/ics/event-builder-with-timezone.ics.license b/tests/data/ics/event-builder-with-timezone.ics.license new file mode 100644 index 0000000000000..23e2d6b19082e --- /dev/null +++ b/tests/data/ics/event-builder-with-timezone.ics.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/tests/lib/Calendar/CalendarEventBuilderTest.php b/tests/lib/Calendar/CalendarEventBuilderTest.php index f4eacbfa85dc6..bf2a4a20ec576 100644 --- a/tests/lib/Calendar/CalendarEventBuilderTest.php +++ b/tests/lib/Calendar/CalendarEventBuilderTest.php @@ -98,6 +98,42 @@ public function testCreateInCalendar(): void { $this->assertEquals('event-uid-123.ics', $actual); } + public function testToIcsWithTimezone(): void { + $tz = new \DateTimeZone('Europe/Berlin'); + $this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2026-05-11T11:00:00', $tz)); + $this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2026-05-11T12:00:00', $tz)); + $this->calendarEventBuilder->setStatus(CalendarEventStatus::CONFIRMED); + $this->calendarEventBuilder->setSummary('My event'); + $this->calendarEventBuilder->setDescription('Foo bar baz'); + $this->calendarEventBuilder->setOrganizer('organizer@domain.tld', 'Organizer'); + $this->calendarEventBuilder->addAttendee('attendee@domain.tld', 'Attendee'); + + $actual = $this->calendarEventBuilder->toIcs(); + + // TZID must use the globally-defined form (RFC 5545 §3.2.19) so no VTIMEZONE is needed + $this->assertStringContainsString('DTSTART;TZID=/Europe/Berlin:', $actual); + $this->assertStringContainsString('DTEND;TZID=/Europe/Berlin:', $actual); + $this->assertStringNotContainsString('BEGIN:VTIMEZONE', $actual); + + $expected = file_get_contents(\OC::$SERVERROOT . '/tests/data/ics/event-builder-with-timezone.ics'); + $this->assertEquals($expected, $actual); + } + + public function testToIcsWithUtcIsUnchanged(): void { + // UTC datetimes must stay as-is (Z suffix, no TZID parameter) + $this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2026-05-11T09:00:00Z')); + $this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2026-05-11T10:00:00Z')); + $this->calendarEventBuilder->setStatus(CalendarEventStatus::CONFIRMED); + $this->calendarEventBuilder->setSummary('My event'); + + $actual = $this->calendarEventBuilder->toIcs(); + + $this->assertStringContainsString('DTSTART:20260511T090000Z', $actual); + $this->assertStringContainsString('DTEND:20260511T100000Z', $actual); + $this->assertStringNotContainsString('TZID', $actual); + $this->assertStringNotContainsString('BEGIN:VTIMEZONE', $actual); + } + public function testToIcsWithoutStartDate(): void { $this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); $this->calendarEventBuilder->setSummary('My event');