Issue Description
A recurring JMAP CalendarEvent is stored and returned correctly by CalendarEvent/get, but CalendarEvent/query with expandRecurrences: true can return no expanded occurrences once the series crosses a DST fall-back transition.
My affected event is a local-time daily recurrence in Pacific/Auckland, every three days at 02:20. That local time is ambiguous during Auckland’s DST fall-back overlap in April 2026.
The recurrence expansion code converts local timestamps using Chrono’s single(). For an ambiguous local timestamp, single() returns None. This propagates through expansion and is silently converted into an empty result by the JMAP query path.
The included patch changes ambiguous-time resolution to choose the earliest valid instant, rather than failing the entire recurrence expansion. It also adds a regression test.
Expected Behavior
CalendarEvent/query with expandRecurrences: true should return all occurrences overlapping the requested time range, including recurrences before and after a DST transition.
For an ambiguous local recurrence time during a fall-back overlap, Stalwart should resolve it consistently rather than returning an empty recurrence expansion.
Actual Behavior
The base event is returned by CalendarEvent/get:
{
"id": "3",
"start": "2026-01-08T02:20:00",
"duration": "PT2H10M",
"timeZone": "Pacific/Auckland",
"recurrenceRule": {
"frequency": "daily",
"interval": 3,
"firstDayOfWeek": "su"
},
"calendarIds": {
"c": true
}
}
However, an expanded query over a window containing occurrences returns:
{
"accountId": "c",
"ids": [],
"total": 0
}
The issue occurs after the series reaches the Pacific/Auckland DST overlap around 2026-04-05 02:20.
Reproduction Steps
- Create or import a CalendarEvent with:
{
"start": "2026-01-08T02:20:00",
"duration": "PT2H10M",
"timeZone": "Pacific/Auckland",
"recurrenceRule": {
"frequency": "daily",
"interval": 3
}
}
- Query an interval after the Auckland DST fall-back transition, for example:
{
"using": [
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:calendars"
],
"methodCalls": [
[
"CalendarEvent/query",
{
"accountId": "c",
"filter": {
"after": "2026-07-01T00:00:00",
"before": "2026-08-12T00:00:00"
},
"expandRecurrences": true,
"calculateTotal": true
},
"q"
]
]
}
- Observe that no occurrence IDs are returned, despite expected occurrences such as
2026-07-01T02:20:00and2026-07-04T02:20:00.
Relevant Log Output
No relevant log entry was emitted. The failing expansion was silently treated as an empty result through unwrap_or_default() in the JMAP query path.
Stalwart Version
v0.16.x
Installation Method
Docker
Database Backend
RocksDB
Blob Storage
RocksDB
Search Engine
Internal
Directory Backend
Internal
Additional Context
Patch:
commit 872b4acca08e52cb84aa67266768fd6cbc5efd22
Author: ripdog <[email protected]>
Date: Sat Jul 18 12:36:52 2026 +1200
Fix calendar recurrence expansion across DST overlaps
diff --git a/crates/groupware/src/calendar/expand.rs b/crates/groupware/src/calendar/expand.rs
index 632ba67b..b2e949de 100644
--- a/crates/groupware/src/calendar/expand.rs
+++ b/crates/groupware/src/calendar/expand.rs
@@ -55,18 +55,30 @@ 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
+ // A local recurrence can fall on a DST transition. Do not
+ // discard the entire series when that happens: use the first
+ // valid instant for an overlap and skip only a nonexistent
+ // local time (a spring-forward gap).
+ let Some(start) = start_tz
.from_local_datetime(
&DateTime::from_timestamp(start_date_naive, 0)?.naive_local(),
)
- .single()?
- .timestamp();
- let end = end_tz
+ .earliest()
+ .map(|dt| dt.timestamp())
+ else {
+ expansion_id += 1;
+ continue;
+ };
+ let Some(end) = end_tz
.from_local_datetime(
&DateTime::from_timestamp(end_date_naive, 0)?.naive_local(),
)
- .single()?
- .timestamp();
+ .earliest()
+ .map(|dt| dt.timestamp())
+ else {
+ expansion_id += 1;
+ continue;
+ };
if limit.is_in_range(is_todo, start, end) {
expansion.push(CalendarEventExpansion {
@@ -89,13 +101,13 @@ impl ArchivedCalendarEventData {
.from_local_datetime(
&DateTime::from_timestamp(start_date_naive, 0)?.naive_local(),
)
- .single()?
+ .earliest()?
.timestamp();
let end = end_tz
.from_local_datetime(
&DateTime::from_timestamp(end_date_naive, 0)?.naive_local(),
)
- .single()?
+ .earliest()?
.timestamp();
if limit.is_in_range(is_todo, start, end) {
@@ -161,13 +173,13 @@ impl CalendarEventData {
.from_local_datetime(
&DateTime::from_timestamp(start_date_naive, 0)?.naive_local(),
)
- .single()?
+ .earliest()?
.timestamp();
let end = end_tz
.from_local_datetime(
&DateTime::from_timestamp(end_date_naive, 0)?.naive_local(),
)
- .single()?
+ .earliest()?
.timestamp();
expansion.push(CalendarEventExpansion {
@@ -198,13 +210,13 @@ impl CalendarEventData {
.from_local_datetime(
&DateTime::from_timestamp(start_date_naive, 0)?.naive_local(),
)
- .single()?
+ .earliest()?
.timestamp();
let end = end_tz
.from_local_datetime(
&DateTime::from_timestamp(end_date_naive, 0)?.naive_local(),
)
- .single()?
+ .earliest()?
.timestamp();
expansion.push(CalendarEventExpansion {
@@ -265,11 +277,11 @@ impl CalendarEventData {
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()?
+ .earliest()?
.timestamp();
let end = end_tz
.from_local_datetime(&DateTime::from_timestamp(end_date_naive, 0)?.naive_local())
- .single()?
+ .earliest()?
.timestamp();
Some(CalendarEventExpansion {
@@ -292,6 +304,47 @@ impl Default for CalendarEventExpansion {
}
}
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use calcard::icalendar::ICalendar;
+ use store::{
+ Deserialize, Serialize,
+ write::{AlignedBytes, Archive, Archiver},
+ };
+
+ #[test]
+ fn expands_recurrences_across_a_dst_overlap() {
+ let calendar = ICalendar::parse(
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:dst-overlap\r\nDTSTART;TZID=Pacific/Auckland:20260108T022000\r\nDURATION:PT2H10M\r\nRRULE:FREQ=DAILY;INTERVAL=3\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
+ )
+ .unwrap();
+ let mut next_alarm = None;
+ let mut event = CalendarEventData::new(calendar, Tz::Floating, 128, &mut next_alarm);
+ // The date builder preserves an overlap as floating. Model the stored
+ // Pacific/Auckland range that query expansion must also tolerate.
+ let pacific_tz = event
+ .time_ranges
+ .iter()
+ .find(|range| !Tz::from_id(range.start_tz).unwrap().is_floating())
+ .unwrap()
+ .start_tz;
+ for range in &mut event.time_ranges {
+ range.start_tz = pacific_tz;
+ range.end_tz = pacific_tz;
+ }
+ let bytes = Archiver::new(event).serialize().unwrap();
+ let archived = <Archive<AlignedBytes> as Deserialize>::deserialize(&bytes).unwrap();
+ let event = archived.unarchive::<CalendarEventData>().unwrap();
+ let range = TimeRange {
+ start: 1_775_820_000, // 2026-04-01T00:00:00Z
+ end: 1_776_684_800, // 2026-04-11T00:00:00Z
+ };
+
+ assert!(!event.expand(Tz::UTC, range).unwrap().is_empty());
+ }
+}
+
impl CalendarEventExpansion {
pub fn is_valid(&self) -> bool {
self.comp_id != u32::MAX && self.start != i64::MAX && self.end != i64::MAX
Happy to submit as a pull request if permitted.
I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.
on
I have searched this support forum (open and closed topics) and confirm this is not a duplicate.
on
I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.
on