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)] + ); + } +}