Calendar: keep the UTC zone of a series that starts in UTC #305

Merged
jcoffey-dev merged 1 commits from fix/recurring-event-time-zone into main 2026-11-06 20:27:41 +00:00
6 changed files with 364 additions and 48 deletions

No files matched your search

+10 -10
View File
@@ -2,10 +2,13 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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::<u32>()?;
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
+156 -32
View File
@@ -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<Tz>;
}
impl ComponentStartOffset for ArchivedICalendarComponent {
fn start_offset_tz(&self) -> Option<Tz> {
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<Tz> {
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<RecurrenceId>, 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::<u32>()?;
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:[email protected]\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::<rkyv::rancor::Error>(data).unwrap();
let archived =
rkyv::access::<ArchivedCalendarEventData, rkyv::rancor::Error>(&bytes).unwrap();
let all = archived
.expand(tz, TimeRange::new(i64::MIN, i64::MAX))
.expect("expansion")
.into_iter()
.map(|e| (e.start, e.end))
.collect::<Vec<_>>();
(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()
)
);
}
}
+13 -5
View File
@@ -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::<Vec<_>>(),
};
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 {
+107
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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": "[email protected]",
"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::<Vec<_>>();
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<Value> {
let ids = account
.jmap_query(
+7
View File
@@ -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
+71 -1
View File
@@ -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)]
);
}
}