Summary
Sabre VObject has two related gaps around RFC 5545 §3.2.19 (globally-defined timezone identifiers):
- Parsing bug:
TimeZoneUtil::findTimeZone() does not strip the leading / from a globally-defined TZID, so TZID=/Europe/Berlin silently falls back to the PHP default timezone instead of resolving to Europe/Berlin.
- Serialisation issue:
DateTime::setDateTimes() emits the bare TZID form (TZID=Europe/Berlin) but never adds a VTIMEZONE component. Per RFC 5545 §4.2.19, a bare TZID must be accompanied by a VTIMEZONE definition in the same object. Without it the produced ICS is technically invalid, and some clients (e.g. Grommunio) misinterpret the time.
RFC 5545 §3.2.19 provides an escape hatch: a TZID prefixed with / references a globally-defined (IANA) timezone registry entry and requires no accompanying VTIMEZONE component. Since Sabre already derives TZID values from PHP DateTimeZone objects (which are always IANA identifiers), using the global form would produce valid ICS without needing to generate VTIMEZONE data.
Affected code
Parsing — lib/TimeZoneUtil.php, findTimeZone():
private function findTimeZone(string $tzid, ...): DateTimeZone
{
foreach ($this->timezoneFinders as $timezoneFinder) {
$timezone = $timezoneFinder->find($tzid, $failIfUncertain); // '/Europe/Berlin' passed as-is
FindFromTimezoneIdentifier::find() calls in_array($tzid, DateTimeZone::listIdentifiers()) which returns false for '/Europe/Berlin', so the lookup silently falls through.
Serialisation — lib/Property/ICalendar/DateTime.php, setDateTimes() line 209:
$this->offsetSet('TZID', $tz->getName()); // emits 'Europe/Berlin', no VTIMEZONE added
Proposed fix
Parsing: strip a leading / before passing to the timezone finders (and before comparing against VTIMEZONE->TZID):
// RFC 5545 §3.2.19: strip leading '/' from globally-defined IANA identifiers
$tzid = ltrim($tzid, '/');
Serialisation: emit the global form so no VTIMEZONE is needed:
$this->offsetSet('TZID', '/' . $tz->getName());
The serialisation change is a behaviour change — callers who inspect the raw TZID parameter string would see the / prefix. The parsing fix is strictly additive and safe.
Workarounds in the wild
This gap has already caused downstream issues:
Both workarounds use the same regex (preg_replace('/;TZID=(?!\/)/', ';TZID=/', $ics)) to add the / prefix after the fact, rather than fixing it at the Sabre level.
RFC reference
TZID Parameter: To make it possible to reference global time zone definitions, the TZID parameter value MUST be prefixed by "/".
— RFC 5545 §3.2.19
Summary
Sabre VObject has two related gaps around RFC 5545 §3.2.19 (globally-defined timezone identifiers):
TimeZoneUtil::findTimeZone()does not strip the leading/from a globally-defined TZID, soTZID=/Europe/Berlinsilently falls back to the PHP default timezone instead of resolving toEurope/Berlin.DateTime::setDateTimes()emits the bare TZID form (TZID=Europe/Berlin) but never adds aVTIMEZONEcomponent. Per RFC 5545 §4.2.19, a bare TZID must be accompanied by aVTIMEZONEdefinition in the same object. Without it the produced ICS is technically invalid, and some clients (e.g. Grommunio) misinterpret the time.RFC 5545 §3.2.19 provides an escape hatch: a TZID prefixed with
/references a globally-defined (IANA) timezone registry entry and requires no accompanyingVTIMEZONEcomponent. Since Sabre already derives TZID values from PHPDateTimeZoneobjects (which are always IANA identifiers), using the global form would produce valid ICS without needing to generate VTIMEZONE data.Affected code
Parsing —
lib/TimeZoneUtil.php,findTimeZone():FindFromTimezoneIdentifier::find()callsin_array($tzid, DateTimeZone::listIdentifiers())which returnsfalsefor'/Europe/Berlin', so the lookup silently falls through.Serialisation —
lib/Property/ICalendar/DateTime.php,setDateTimes()line 209:Proposed fix
Parsing: strip a leading
/before passing to the timezone finders (and before comparing againstVTIMEZONE->TZID):Serialisation: emit the global form so no VTIMEZONE is needed:
The serialisation change is a behaviour change — callers who inspect the raw TZID parameter string would see the
/prefix. The parsing fix is strictly additive and safe.Workarounds in the wild
This gap has already caused downstream issues:
Both workarounds use the same regex (
preg_replace('/;TZID=(?!\/)/', ';TZID=/', $ics)) to add the/prefix after the fact, rather than fixing it at the Sabre level.RFC reference