From 21d859aa4c62e9771f41b5b5ec2549cd4a3ff614 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 6 Nov 2026 13:11:52 -0700 Subject: [PATCH] Calendar: keep the UTC zone of a series that starts in UTC A recurring event whose DTSTART is written in UTC ("...Z", as a JMAP timeZone of Etc/UTC is stored) came back in the zone of the request: read with America/Phoenix, an 18:00 UTC weekly event said 01:00Z and, for its occurrences, America/Phoenix. Named-zone and floating series and one-off events were right. calcard took a component's start zone from its TZID or the default, and the server's default is floating, so every instance of a UTC rule was stored floating and read in the request's zone. The DTSTART's own offset now wins, as in calcard 0.4. Series already stored floating are read in their DTSTART's offset when it has one, so no reindex is needed. Occurrences of a UTC series are labeled Etc/UTC like the base event, and the base event's utcStart is worked out before the event is taken out of its data. Fixes #303. --- crates/groupware/src/calendar/alarm.rs | 20 +-- crates/groupware/src/calendar/expand.rs | 188 ++++++++++++++++++++---- crates/jmap/src/calendar_event/get.rs | 18 ++- tests/src/jmap/calendar/instance.rs | 107 ++++++++++++++ vendor/calcard/VENDORED.md | 7 + vendor/calcard/src/icalendar/dates.rs | 72 ++++++++- 6 files changed, 364 insertions(+), 48 deletions(-) diff --git a/crates/groupware/src/calendar/alarm.rs b/crates/groupware/src/calendar/alarm.rs index 2bc59fd3..a40272d2 100644 --- a/crates/groupware/src/calendar/alarm.rs +++ b/crates/groupware/src/calendar/alarm.rs @@ -2,10 +2,13 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{ - Alarm, AlarmDelta, ArchivedAlarmDelta, ArchivedCalendarEventData, expand::resolve_local, + Alarm, AlarmDelta, ArchivedAlarmDelta, ArchivedCalendarEventData, + expand::{range_zones, resolve_local}, }; use calcard::{ common::timezone::Tz, @@ -58,15 +61,12 @@ impl ArchivedCalendarEventData { let (offset_or_count, bytes_read) = instances.read_leb128::()?; let duration = range.duration.to_native() as i64; - let mut start_tz = Tz::from_id(range.start_tz.to_native())?; - let mut end_tz = Tz::from_id(range.end_tz.to_native())?; - - if start_tz.is_floating() && !default_tz.is_floating() { - start_tz = default_tz; - } - if end_tz.is_floating() && !default_tz.is_floating() { - end_tz = default_tz; - } + let (start_tz, end_tz) = range_zones( + Tz::from_id(range.start_tz.to_native())?, + Tz::from_id(range.end_tz.to_native())?, + self.event.components.get(comp_id as usize), + default_tz, + ); if instances.len() > bytes_read { // Recurring event diff --git a/crates/groupware/src/calendar/expand.rs b/crates/groupware/src/calendar/expand.rs index d736f3a2..792d0cf1 100644 --- a/crates/groupware/src/calendar/expand.rs +++ b/crates/groupware/src/calendar/expand.rs @@ -102,6 +102,70 @@ fn resolve_recurrence_id( }) } +// inbuxa: the zone of a DTSTART written with an offset ("...Z") and no TZID. +// Series stored before calcard kept that zone have floating time ranges, and +// read as floating they land in whatever zone the request asks for. +pub trait ComponentStartOffset { + fn start_offset_tz(&self) -> Option; +} + +impl ComponentStartOffset for ArchivedICalendarComponent { + fn start_offset_tz(&self) -> Option { + let entry = self.property(&ICalendarProperty::Dtstart)?; + if entry.tz_id().is_some() { + return None; + } + entry + .values + .first()? + .as_partial_date_time()? + .to_date_time()? + .tz() + } +} + +impl ComponentStartOffset for ICalendarComponent { + fn start_offset_tz(&self) -> Option { + let entry = self.property(&ICalendarProperty::Dtstart)?; + if entry.tz_id().is_some() { + return None; + } + entry + .values + .first()? + .as_partial_date_time()? + .to_date_time()? + .tz() + } +} + +// inbuxa: the zones to read a time range in. A floating range is in the +// component's DTSTART offset if it has one (see `ComponentStartOffset`), and +// otherwise in `default_tz`, the request's zone. +pub fn range_zones( + start_tz: Tz, + end_tz: Tz, + component: Option<&impl ComponentStartOffset>, + default_tz: Tz, +) -> (Tz, Tz) { + let (mut start, mut end) = (start_tz, end_tz); + if start.is_floating() + && let Some(offset_tz) = component.and_then(|c| c.start_offset_tz()) + { + start = offset_tz; + if end.is_floating() { + end = offset_tz; + } + } + if start.is_floating() && !default_tz.is_floating() { + start = default_tz; + } + if end.is_floating() && !default_tz.is_floating() { + end = default_tz; + } + (start, end) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct CalendarEventExpansion { pub comp_id: u32, @@ -144,17 +208,14 @@ impl ArchivedCalendarEventData { .all(|prior| prior.id != range.id) .then(|| component.recurrence_id(component_tz)) .flatten(); - let mut start_tz = component_tz; - let mut end_tz = Tz::from_id(range.end_tz.to_native())?; + let (start_tz, end_tz) = range_zones( + component_tz, + Tz::from_id(range.end_tz.to_native())?, + Some(component), + default_tz, + ); let is_todo = component.component_type.is_todo(); - if start_tz.is_floating() && !default_tz.is_floating() { - start_tz = default_tz; - } - if end_tz.is_floating() && !default_tz.is_floating() { - end_tz = default_tz; - } - if instances.len() > bytes_read { let unpacker = BitpackIterator::from_bytes_and_offset(instances, bytes_read, offset_or_count); @@ -234,15 +295,12 @@ impl CalendarEventData { .all(|prior| prior.id != range.id) .then(|| component.recurrence_id(component_tz)) .flatten(); - let mut start_tz = component_tz; - let mut end_tz = Tz::from_id(range.end_tz)?; - - if start_tz.is_floating() && !default_tz.is_floating() { - start_tz = default_tz; - } - if end_tz.is_floating() && !default_tz.is_floating() { - end_tz = default_tz; - } + let (start_tz, end_tz) = range_zones( + component_tz, + Tz::from_id(range.end_tz)?, + Some(component), + default_tz, + ); let mut push_instance = |own_recurrence_id: Option, start_offset: u32| { let start_date_naive = start_offset as i64 + base_offset; @@ -294,20 +352,15 @@ impl CalendarEventData { let instances = range.instances.as_ref(); let (offset_or_count, bytes_read) = instances.read_leb128::()?; let component_tz = Tz::from_id(range.start_tz)?; - let own_recurrence_id = self - .event - .components - .get(comp_id as usize) - .and_then(|component| component.recurrence_id(component_tz)); - let mut start_tz = component_tz; - let mut end_tz = Tz::from_id(range.end_tz)?; - - if start_tz.is_floating() && !default_tz.is_floating() { - start_tz = default_tz; - } - if end_tz.is_floating() && !default_tz.is_floating() { - end_tz = default_tz; - } + let component = self.event.components.get(comp_id as usize); + let own_recurrence_id = + component.and_then(|component| component.recurrence_id(component_tz)); + let (start_tz, end_tz) = range_zones( + component_tz, + Tz::from_id(range.end_tz)?, + component, + default_tz, + ); let start_offset = if instances.len() > bytes_read { let mut unpacker = BitpackIterator::from_bytes_and_offset(instances, bytes_read, offset_or_count); @@ -508,4 +561,75 @@ mod tests { .expect("expansion of an oversized RRULE must be refused, not attempted"); assert_eq!(ranges, 1, "the base instance is still indexed"); } + + // inbuxa: a series starting in UTC is read in UTC whatever zone the + // request asks for, including one stored before calcard kept the zone + #[test] + fn utc_series_ignores_the_request_zone() { + let phoenix = Tz::from_str("America/Phoenix").unwrap(); + let ical = |dtstart: &str| { + let Entry::ICalendar(ical) = Parser::new(&format!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n\ + BEGIN:VEVENT\r\nUID:utc@example.com\r\nDTSTAMP:20270101T000000Z\r\n\ + DTSTART{dtstart}\r\nDURATION:PT1H\r\nRRULE:FREQ=WEEKLY;COUNT=3\r\n\ + END:VEVENT\r\nEND:VCALENDAR\r\n" + )) + .entry() else { + panic!("failed to parse iCalendar"); + }; + CalendarEventData::new(ical, Tz::Floating, 1000, &mut None) + }; + let starts = |data: &CalendarEventData, tz: Tz| { + let first = data.expand_single(1, tz).expect("first instance").start; + let mut keys = AHashSet::from_iter([key(2027, 3, 8, 9, 0)]); + let second = data.expand_from_ids(&mut keys, tz).expect("expansion"); + assert_eq!(second.len(), 1); + let bytes = rkyv::to_bytes::(data).unwrap(); + let archived = + rkyv::access::(&bytes).unwrap(); + let all = archived + .expand(tz, TimeRange::new(i64::MIN, i64::MAX)) + .expect("expansion") + .into_iter() + .map(|e| (e.start, e.end)) + .collect::>(); + (first, second[0].start, all) + }; + let utc = |day| naive(2027, 3, day, 9, 0, 0); + let expected = ( + utc(1), + utc(8), + [1, 8, 15].map(|day| (utc(day), utc(day) + 3600)).to_vec(), + ); + + let mut data = ical(":20270301T090000Z"); + assert!( + data.time_ranges + .iter() + .all(|range| range.start_tz == Tz::UTC.as_id()) + ); + assert_eq!(starts(&data, phoenix), expected); + assert_eq!(starts(&data, Tz::UTC), expected); + + // As stored before the fix: floating ranges + for range in &mut data.time_ranges { + range.start_tz = Tz::Floating.as_id(); + range.end_tz = Tz::Floating.as_id(); + } + assert_eq!(starts(&data, phoenix), expected); + + // A floating series still follows the request's zone + let floating = ical(":20270301T090000"); + let seven = 7 * 3600; + assert_eq!( + starts(&floating, phoenix), + ( + utc(1) + seven, + utc(8) + seven, + [1, 8, 15] + .map(|day| (utc(day) + seven, utc(day) + seven + 3600)) + .to_vec() + ) + ); + } } diff --git a/crates/jmap/src/calendar_event/get.rs b/crates/jmap/src/calendar_event/get.rs index ae646dc6..9831dc06 100644 --- a/crates/jmap/src/calendar_event/get.rs +++ b/crates/jmap/src/calendar_event/get.rs @@ -24,7 +24,7 @@ use groupware::{ calendar::{ CalendarEvent, EVENT_DRAFT, EVENT_HIDE_ATTENDEES, EVENT_INVITE_OTHERS, EVENT_INVITE_SELF, PREF_USE_DEFAULT_ALERTS, - expand::CalendarEventExpansion, + expand::{CalendarEventExpansion, ComponentStartOffset}, limit_component_depth, privacy::{event_view, hide_attendees, redact_private_event}, }, @@ -344,7 +344,12 @@ impl CalendarEventGet for Server { .collect::>(), }; - let tz = tz.unwrap_or(default_tz); + // inbuxa: a series that starts in UTC ("...Z") keeps + // that zone, as the base event does, rather than + // taking the request's + let tz = tz + .or_else(|| source_component.start_offset_tz()) + .unwrap_or(default_tz); let tz_name = tz.name().unwrap_or_default().to_string(); let start_timestamp = DateTime::from_timestamp(expansion.start, 0) @@ -443,11 +448,13 @@ impl CalendarEventGet for Server { } if include_base_event { - let mut event = std::mem::take(&mut calendar_event.data.event); - // Obtain UTC start/end if requested + // inbuxa: before the event is taken, as the expansion reads + // its DTSTART to place a series stored floating let expansion = if return_utc_dates - && let Some(expansion) = event + && let Some(expansion) = calendar_event + .data + .event .components .iter() .position(|c| { @@ -462,6 +469,7 @@ impl CalendarEventGet for Server { } else { CalendarEventExpansion::default() }; + let mut event = std::mem::take(&mut calendar_event.data.event); // Remove recurrence ids if let Some(range) = &override_range { diff --git a/tests/src/jmap/calendar/instance.rs b/tests/src/jmap/calendar/instance.rs index 3fee8912..0c142354 100644 --- a/tests/src/jmap/calendar/instance.rs +++ b/tests/src/jmap/calendar/instance.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -884,6 +886,8 @@ pub async fn test(test: &TestServer) { ] ); + utc_series(&account).await; + // Clean up for calendar_id in [&calendar_id, &series_calendar_id] { account @@ -896,6 +900,109 @@ pub async fn test(test: &TestServer) { } } +// inbuxa: a series starting in UTC keeps its times and zone whatever zone +// the request asks for (inbuxa-server #303) +async fn utc_series(account: &Account) { + let calendar_id = account + .jmap_create( + MethodObject::Calendar, + [json!({ "name": "UTC series" })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let event_id = account + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "@type": "Event", + "uid": "utc-series@example.com", + "calendarIds": { &calendar_id: true }, + "title": "UTC series", + "start": "2026-11-02T18:00:00", + "duration": "PT1H", + "timeZone": "Etc/UTC", + "recurrenceRule": { "frequency": "weekly", "count": 3 } + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let account_id = account.id_string(); + let properties = ["id", "baseEventId", "start", "timeZone", "utcStart"]; + + for time_zone in ["America/Phoenix", "Etc/UTC"] { + let response = account + .jmap_method_calls(json!([ + ["CalendarEvent/get", { + "accountId": account_id, + "ids": [&event_id], + "properties": properties, + "timeZone": time_zone, + }, "0"], + ["CalendarEvent/query", { + "accountId": account_id, + "filter": { + "inCalendar": &calendar_id, + "after": "2026-11-01T00:00:00", + "before": "2026-12-01T00:00:00", + }, + "sort": [{ "property": "start" }], + "expandRecurrences": true, + "timeZone": time_zone, + }, "1"], + ["CalendarEvent/get", { + "accountId": account_id, + "#ids": { "resultOf": "1", "name": "CalendarEvent/query", "path": "/ids" }, + "properties": properties, + "timeZone": time_zone, + }, "2"], + ])) + .await; + let base = &response.list()[0]; + assert_eq!(base.text_field("timeZone"), "Etc/UTC", "{time_zone}"); + assert_eq!( + base.text_field("utcStart"), + "2026-11-02T18:00:00Z", + "{time_zone}" + ); + + let instances = response.response_at(2)["list"].as_array().unwrap(); + let found = instances + .iter() + .map(|instance| { + assert_eq!(instance.text_field("baseEventId"), event_id); + ( + instance.text_field("start"), + instance.text_field("timeZone"), + instance.text_field("utcStart"), + ) + }) + .collect::>(); + assert_eq!( + found, + [ + ("2026-11-02T18:00:00", "Etc/UTC", "2026-11-02T18:00:00Z"), + ("2026-11-09T18:00:00", "Etc/UTC", "2026-11-09T18:00:00Z"), + ("2026-11-16T18:00:00", "Etc/UTC", "2026-11-16T18:00:00Z"), + ], + "{time_zone}" + ); + } + + account + .jmap_destroy( + MethodObject::Calendar, + [&calendar_id], + [("onDestroyRemoveEvents", true)], + ) + .await; +} + async fn expand_instances(account: &Account, calendar_id: &str) -> Vec { let ids = account .jmap_query( diff --git a/vendor/calcard/VENDORED.md b/vendor/calcard/VENDORED.md index b21b3567..21a94457 100644 --- a/vendor/calcard/VENDORED.md +++ b/vendor/calcard/VENDORED.md @@ -44,6 +44,13 @@ Every changed file carries a Coffey Labs notice in its header. limit is reached (they couldn't add anything). The `budget_tests` in `dates.rs` cover it, and `rule_expansion_shares_one_budget` in `crates/groupware` fails to build if the server stops using this copy. +- `src/icalendar/dates.rs`: a component whose DTSTART carries an offset + (`DTSTART:20261102T180000Z`) has that zone as its start zone. Upstream + took the default zone, which the server passes as floating, so every + instance of a UTC rule came out floating (one-off events were already + right), and the server then read the series in the zone each request asked + for. The `start_zone_tests` in `dates.rs` cover it. calcard 0.4 makes the + same change. ## Updating diff --git a/vendor/calcard/src/icalendar/dates.rs b/vendor/calcard/src/icalendar/dates.rs index 6180369c..e519caac 100644 --- a/vendor/calcard/src/icalendar/dates.rs +++ b/vendor/calcard/src/icalendar/dates.rs @@ -393,7 +393,13 @@ impl ICalendarComponent { }, }; let mut event = None; - let start_tz = tz_resolver.resolve_or_default(dt_start_tzid); + // inbuxa: a DTSTART written with an offset ("...Z") is in that zone. + // Taking the default (floating, as the server stores events) made + // every instance of a UTC rule floating, so readers then placed it in + // whatever zone the client asked for. + let start_tz = dt_start + .tz() + .unwrap_or_else(|| tz_resolver.resolve_or_default(dt_start_tzid)); let dt_start_tz = dt_start .to_date_time_with_tz(start_tz) .ok_or(CalendarErrorType::InvalidDtStart)?; @@ -771,3 +777,67 @@ mod budget_tests { assert!(iterations < 4000, "{iterations}"); } } + +#[cfg(test)] +mod start_zone_tests { + use crate::{ + common::timezone::Tz, + icalendar::{ICalendar, dates::TimeOrDelta}, + }; + + fn instances(dtstart: &str, rrule: Option<&str>, default_tz: Tz) -> Vec<(Tz, i64)> { + let rrule = rrule.map_or(String::new(), |rule| format!("RRULE:{rule}\r\n")); + let ical = ICalendar::parse(format!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:zone\r\n\ + DTSTART{dtstart}\r\nDURATION:PT1H\r\n{rrule}END:VEVENT\r\nEND:VCALENDAR\r\n" + )) + .unwrap(); + ical.expand_dates(default_tz, 10) + .events + .into_iter() + .map(|event| { + if let TimeOrDelta::Time(end) = event.end { + assert_eq!(end.timezone(), event.start.timezone()); + } + (event.start.timezone(), event.start.timestamp()) + }) + .collect() + } + + // 2026-11-02 18:00 UTC and a week later + const FIRST: i64 = 1793642400; + const SECOND: i64 = FIRST + 7 * 86400; + + #[test] + fn utc_rule_instances_stay_utc() { + for default_tz in [Tz::Floating, Tz::Tz(chrono_tz::America::Phoenix)] { + assert_eq!( + instances(":20261102T180000Z", Some("FREQ=WEEKLY;COUNT=2"), default_tz), + vec![(Tz::UTC, FIRST), (Tz::UTC, SECOND)], + "{default_tz:?}" + ); + // as a one-off already was + assert_eq!( + instances(":20261102T180000Z", None, default_tz), + vec![(Tz::UTC, FIRST)] + ); + } + } + + #[test] + fn floating_and_zoned_rules_unchanged() { + assert_eq!( + instances(":20261102T180000", Some("FREQ=WEEKLY;COUNT=2"), Tz::Floating), + vec![(Tz::Floating, FIRST), (Tz::Floating, SECOND)] + ); + let new_york = Tz::Tz(chrono_tz::America::New_York); + assert_eq!( + instances( + ";TZID=America/New_York:20261102T130000", + Some("FREQ=WEEKLY;COUNT=2"), + Tz::Floating + ), + vec![(new_york, FIRST), (new_york, SECOND)] + ); + } +} -- 2.54.0