Skip to content

Commit e1d05d0

Browse files
committed
fix(calendar): prefix TZID with '/' to use globally-defined IANA timezone identifiers
Without a leading '/', a TZID like 'Europe/Berlin' in DTSTART/DTEND requires an accompanying VTIMEZONE component (RFC 5545 §3.2.19). CalendarEventBuilder never emits VTIMEZONE, so Sabre's iTIP broker had nothing to copy into invite emails — leaving recipients with a dangling TZID reference that clients like Grommunio misinterpret as an unknown timezone. Prepending '/' makes the TZID a globally-defined IANA identifier, which is valid without VTIMEZONE and is universally understood. UTC datetimes (Z suffix, no TZID) are unaffected. AI-Assisted-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Anna Larch <anna@nextcloud.com>
1 parent 887dfeb commit e1d05d0

4 files changed

Lines changed: 71 additions & 0 deletions

File tree

lib/private/Calendar/CalendarEventBuilder.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,21 @@ public function toIcs(): string {
123123
foreach ($this->attendees as $attendee) {
124124
self::addAttendeeToVEvent($vevent, 'ATTENDEE', $attendee);
125125
}
126+
127+
// Prefix TZID values with '/' to reference globally-defined IANA timezone identifiers
128+
// (RFC 5545 §3.2.19). This avoids the need for a VTIMEZONE component: without it,
129+
// iTIP invite emails carry TZID=Europe/Berlin with no accompanying VTIMEZONE block,
130+
// and some clients (e.g. Grommunio) misinterpret the time.
131+
foreach (['DTSTART', 'DTEND'] as $propName) {
132+
$prop = $vevent->$propName;
133+
if ($prop !== null && isset($prop['TZID'])) {
134+
$tzid = (string)$prop['TZID'];
135+
if ($tzid !== '' && !str_starts_with($tzid, '/')) {
136+
$prop['TZID'] = '/' . $tzid;
137+
}
138+
}
139+
}
140+
126141
return $vcalendar->serialize();
127142
}
128143

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
BEGIN:VCALENDAR
2+
VERSION:2.0
3+
PRODID:-//Sabre//Sabre VObject 4.5.6//EN
4+
CALSCALE:GREGORIAN
5+
BEGIN:VEVENT
6+
UID:event-uid-123
7+
DTSTAMP:20250105T000000Z
8+
SUMMARY:My event
9+
DTSTART;TZID=/Europe/Berlin:20260511T110000
10+
DTEND;TZID=/Europe/Berlin:20260511T120000
11+
STATUS:CONFIRMED
12+
DESCRIPTION:Foo bar baz
13+
ORGANIZER;CN=Organizer;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:organizer@domain
14+
.tld
15+
ATTENDEE;CN=Attendee;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION:mailto:atte
16+
ndee@domain.tld
17+
END:VEVENT
18+
END:VCALENDAR
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
2+
SPDX-License-Identifier: AGPL-3.0-or-later

tests/lib/Calendar/CalendarEventBuilderTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,42 @@ public function testCreateInCalendar(): void {
9898
$this->assertEquals('event-uid-123.ics', $actual);
9999
}
100100

101+
public function testToIcsWithTimezone(): void {
102+
$tz = new \DateTimeZone('Europe/Berlin');
103+
$this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2026-05-11T11:00:00', $tz));
104+
$this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2026-05-11T12:00:00', $tz));
105+
$this->calendarEventBuilder->setStatus(CalendarEventStatus::CONFIRMED);
106+
$this->calendarEventBuilder->setSummary('My event');
107+
$this->calendarEventBuilder->setDescription('Foo bar baz');
108+
$this->calendarEventBuilder->setOrganizer('organizer@domain.tld', 'Organizer');
109+
$this->calendarEventBuilder->addAttendee('attendee@domain.tld', 'Attendee');
110+
111+
$actual = $this->calendarEventBuilder->toIcs();
112+
113+
// TZID must use the globally-defined form (RFC 5545 §3.2.19) so no VTIMEZONE is needed
114+
$this->assertStringContainsString('DTSTART;TZID=/Europe/Berlin:', $actual);
115+
$this->assertStringContainsString('DTEND;TZID=/Europe/Berlin:', $actual);
116+
$this->assertStringNotContainsString('BEGIN:VTIMEZONE', $actual);
117+
118+
$expected = file_get_contents(\OC::$SERVERROOT . '/tests/data/ics/event-builder-with-timezone.ics');
119+
$this->assertEquals($expected, $actual);
120+
}
121+
122+
public function testToIcsWithUtcIsUnchanged(): void {
123+
// UTC datetimes must stay as-is (Z suffix, no TZID parameter)
124+
$this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2026-05-11T09:00:00Z'));
125+
$this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2026-05-11T10:00:00Z'));
126+
$this->calendarEventBuilder->setStatus(CalendarEventStatus::CONFIRMED);
127+
$this->calendarEventBuilder->setSummary('My event');
128+
129+
$actual = $this->calendarEventBuilder->toIcs();
130+
131+
$this->assertStringContainsString('DTSTART:20260511T090000Z', $actual);
132+
$this->assertStringContainsString('DTEND:20260511T100000Z', $actual);
133+
$this->assertStringNotContainsString('TZID', $actual);
134+
$this->assertStringNotContainsString('BEGIN:VTIMEZONE', $actual);
135+
}
136+
101137
public function testToIcsWithoutStartDate(): void {
102138
$this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z'));
103139
$this->calendarEventBuilder->setSummary('My event');

0 commit comments

Comments
 (0)