Fix Calendar: No expanded occurrences are returned for a daily recurrences crossing DST

This commit is contained in:
Maurus Decimus
2026-07-19 22:59:03 +02:00
parent 152db36247
commit 2d08933fbc
4 changed files with 115 additions and 108 deletions

View File

@@ -24,6 +24,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If
- CalDAV: `calendar-query` REPORT returns empty calendar-data for JMAP-created events.
- MTA: DMARC is skipped when MAIL FROM SPF is unavailable.
- Calendar:
- No expanded occurrences are returned for a daily recurrences crossing DST.
- Uppercase `MAILTO` calendar addresses become invalid SMTP recipients.
- Scheduling invitations on a shared, non-owned calendar fail with `MAIL FROM unauthorized`.
- HTTP: Disable `allowedEndpoints` expression in recovery mode.

View File

@@ -4,7 +4,9 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{Alarm, AlarmDelta, ArchivedAlarmDelta, ArchivedCalendarEventData};
use super::{
Alarm, AlarmDelta, ArchivedAlarmDelta, ArchivedCalendarEventData, expand::resolve_local,
};
use calcard::{
common::timezone::Tz,
icalendar::{
@@ -12,7 +14,6 @@ use calcard::{
ICalendarRelated, ICalendarValue,
},
};
use chrono::{DateTime, TimeZone};
use std::str::FromStr;
use store::write::bitpack::BitpackIterator;
use utils::codec::leb128::Leb128Reader;
@@ -74,18 +75,12 @@ impl ArchivedCalendarEventData {
for start_offset in unpacker {
let start_date_naive = start_offset as i64 + base_offset;
let end_date_naive = start_date_naive + duration;
let start = start_tz
.from_local_datetime(
&DateTime::from_timestamp(start_date_naive, 0)?.naive_local(),
)
.single()?
.timestamp();
let end = end_tz
.from_local_datetime(
&DateTime::from_timestamp(end_date_naive, 0)?.naive_local(),
)
.single()?
.timestamp();
let (Some(start), Some(end)) = (
resolve_local(start_tz, start_date_naive),
resolve_local(end_tz, end_date_naive),
) else {
continue;
};
if let Some(alarm_time) = alarm.delta.to_timestamp(start, end, default_tz)
&& alarm_time > start_time
@@ -124,18 +119,12 @@ impl ArchivedCalendarEventData {
// Single event
let start_date_naive = offset_or_count as i64 + base_offset;
let end_date_naive = start_date_naive + duration;
let start = start_tz
.from_local_datetime(
&DateTime::from_timestamp(start_date_naive, 0)?.naive_local(),
)
.single()?
.timestamp();
let end = end_tz
.from_local_datetime(
&DateTime::from_timestamp(end_date_naive, 0)?.naive_local(),
)
.single()?
.timestamp();
let (Some(start), Some(end)) = (
resolve_local(start_tz, start_date_naive),
resolve_local(end_tz, end_date_naive),
) else {
continue;
};
if let Some(alarm_time) = alarm.delta.to_timestamp(start, end, default_tz)
&& alarm_time > start_time
@@ -265,10 +254,7 @@ impl AlarmDelta {
AlarmDelta::Start(delta) => Some(start + delta),
AlarmDelta::End(delta) => Some(end + delta),
AlarmDelta::FixedUtc(timestamp) => Some(*timestamp),
AlarmDelta::FixedFloating(timestamp) => default_tz
.from_local_datetime(&DateTime::from_timestamp(*timestamp, 0)?.naive_local())
.single()
.map(|dt| dt.timestamp()),
AlarmDelta::FixedFloating(timestamp) => resolve_local(default_tz, *timestamp),
}
}
}
@@ -279,12 +265,9 @@ impl ArchivedAlarmDelta {
ArchivedAlarmDelta::Start(delta) => Some(start + delta.to_native()),
ArchivedAlarmDelta::End(delta) => Some(end + delta.to_native()),
ArchivedAlarmDelta::FixedUtc(timestamp) => Some(timestamp.to_native()),
ArchivedAlarmDelta::FixedFloating(timestamp) => default_tz
.from_local_datetime(
&DateTime::from_timestamp(timestamp.to_native(), 0)?.naive_local(),
)
.single()
.map(|dt| dt.timestamp()),
ArchivedAlarmDelta::FixedFloating(timestamp) => {
resolve_local(default_tz, timestamp.to_native())
}
}
}
}

View File

@@ -55,18 +55,13 @@ impl ArchivedCalendarEventData {
for start_offset in unpacker {
let start_date_naive = start_offset as i64 + base_offset;
let end_date_naive = start_date_naive + duration;
let start = start_tz
.from_local_datetime(
&DateTime::from_timestamp(start_date_naive, 0)?.naive_local(),
)
.single()?
.timestamp();
let end = end_tz
.from_local_datetime(
&DateTime::from_timestamp(end_date_naive, 0)?.naive_local(),
)
.single()?
.timestamp();
let (Some(start), Some(end)) = (
resolve_local(start_tz, start_date_naive),
resolve_local(end_tz, end_date_naive),
) else {
expansion_id += 1;
continue;
};
if limit.is_in_range(is_todo, start, end) {
expansion.push(CalendarEventExpansion {
@@ -85,20 +80,11 @@ impl ArchivedCalendarEventData {
// Single event
let start_date_naive = offset_or_count as i64 + base_offset;
let end_date_naive = start_date_naive + duration;
let start = start_tz
.from_local_datetime(
&DateTime::from_timestamp(start_date_naive, 0)?.naive_local(),
)
.single()?
.timestamp();
let end = end_tz
.from_local_datetime(
&DateTime::from_timestamp(end_date_naive, 0)?.naive_local(),
)
.single()?
.timestamp();
if limit.is_in_range(is_todo, start, end) {
if let (Some(start), Some(end)) = (
resolve_local(start_tz, start_date_naive),
resolve_local(end_tz, end_date_naive),
) && limit.is_in_range(is_todo, start, end)
{
expansion.push(CalendarEventExpansion {
comp_id,
expansion_id: base_expansion_id,
@@ -157,25 +143,17 @@ impl CalendarEventData {
if expansion_ids.remove(&expansion_id) {
let start_date_naive = start_offset as i64 + base_offset;
let end_date_naive = start_date_naive + range.duration as i64;
let start = start_tz
.from_local_datetime(
&DateTime::from_timestamp(start_date_naive, 0)?.naive_local(),
)
.single()?
.timestamp();
let end = end_tz
.from_local_datetime(
&DateTime::from_timestamp(end_date_naive, 0)?.naive_local(),
)
.single()?
.timestamp();
expansion.push(CalendarEventExpansion {
comp_id: range.id as u32,
expansion_id,
start,
end,
});
if let (Some(start), Some(end)) = (
resolve_local(start_tz, start_date_naive),
resolve_local(end_tz, end_date_naive),
) {
expansion.push(CalendarEventExpansion {
comp_id: range.id as u32,
expansion_id,
start,
end,
});
}
match_count -= 1;
if match_count == 0 {
@@ -194,25 +172,17 @@ impl CalendarEventData {
// Single event
let start_date_naive = offset_or_count as i64 + base_offset;
let end_date_naive = start_date_naive + range.duration as i64;
let start = start_tz
.from_local_datetime(
&DateTime::from_timestamp(start_date_naive, 0)?.naive_local(),
)
.single()?
.timestamp();
let end = end_tz
.from_local_datetime(
&DateTime::from_timestamp(end_date_naive, 0)?.naive_local(),
)
.single()?
.timestamp();
expansion.push(CalendarEventExpansion {
comp_id: range.id as u32,
expansion_id: base_expansion_id,
start,
end,
});
if let (Some(start), Some(end)) = (
resolve_local(start_tz, start_date_naive),
resolve_local(end_tz, end_date_naive),
) {
expansion.push(CalendarEventExpansion {
comp_id: range.id as u32,
expansion_id: base_expansion_id,
start,
end,
});
}
if expansion_ids.is_empty() {
break 'outer;
@@ -263,14 +233,8 @@ impl CalendarEventData {
};
let start_date_naive = start_offset as i64 + self.base_offset;
let end_date_naive = start_date_naive + range.duration as i64;
let start = start_tz
.from_local_datetime(&DateTime::from_timestamp(start_date_naive, 0)?.naive_local())
.single()?
.timestamp();
let end = end_tz
.from_local_datetime(&DateTime::from_timestamp(end_date_naive, 0)?.naive_local())
.single()?
.timestamp();
let start = resolve_local(start_tz, start_date_naive)?;
let end = resolve_local(end_tz, end_date_naive)?;
Some(CalendarEventExpansion {
comp_id,
@@ -297,3 +261,9 @@ impl CalendarEventExpansion {
self.comp_id != u32::MAX && self.start != i64::MAX && self.end != i64::MAX
}
}
pub(crate) fn resolve_local(tz: Tz, naive_secs: i64) -> Option<i64> {
tz.from_local_datetime(&DateTime::from_timestamp(naive_secs, 0)?.naive_local())
.earliest()
.map(|dt| dt.timestamp())
}

View File

@@ -13,6 +13,7 @@ use groupware::{
calendar::{CalendarEventData, alarm::ExpandAlarm, expand::CalendarEventExpansion},
};
use hyper::StatusCode;
use std::str::FromStr;
use store::write::serialize::rkyv_unarchive;
use types::TimeRange;
@@ -342,6 +343,44 @@ fn roundtrip_expansion(ics: &str, ignore_errors: bool) {
assert_eq!(events, events_archive);
}
#[test]
fn calendar_expand_dst_fallback() {
let akl = Tz::from_str("Pacific/Auckland").unwrap();
let ical = ICalendar::parse(ICAL_DST_FALLBACK_ICS).unwrap();
let event_data = CalendarEventData::new(ical, akl, 1000, &mut None);
let expanded_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&event_data).unwrap();
let archive = rkyv_unarchive::<CalendarEventData>(&expanded_bytes).unwrap();
let window = archive
.expand(
akl,
TimeRange {
start: 1782777600,
end: 1786579200,
},
)
.unwrap();
assert!(
!window.is_empty(),
"recurrence expansion aborted across the Pacific/Auckland DST fall-back overlap"
);
let across_overlap = archive
.expand(
akl,
TimeRange {
start: 1775260800,
end: 1775433600,
},
)
.unwrap();
assert_eq!(
across_overlap.len(),
1,
"the ambiguous fall-back occurrence must resolve to its earliest instant"
);
}
fn rfc_file_name(num: usize) -> String {
format!(
"{}/john%40example.com/default/abcd{num}.ics",
@@ -823,6 +862,20 @@ END:VFREEBUSY
END:VCALENDAR
"#;
const ICAL_DST_FALLBACK_ICS: &str = r#"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VEVENT
DTSTAMP:20260108T000000Z
DTSTART;TZID=Pacific/Auckland:20260108T022000
DURATION:PT2H10M
RRULE:FREQ=DAILY;INTERVAL=3
SUMMARY:Auckland DST fall-back recurrence
UID:auckland-dst-fallback@example.com
END:VEVENT
END:VCALENDAR
"#;
const ICAL_RFC_ABCD1_ICS: &str = r#"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN