JMAP CalendarEvent/query returns no expanded occurrences for a daily Pacific/Auckland recurrence crossing DST

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

  1. Create or import a CalendarEvent with:
{
  "start": "2026-01-08T02:20:00",
  "duration": "PT2H10M",
  "timeZone": "Pacific/Auckland",
  "recurrenceRule": {
    "frequency": "daily",
    "interval": 3
  }
}
  1. 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"
    ]
  ]
}
  1. Observe that no occurrence IDs are returned, despite expected occurrences such as 2026-07-01T02:20:00 and 2026-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

This looks like a genuine bug in the recurrence expansion code. I confirmed the current source (crates/groupware/src/calendar/expand.rs) still calls .single()? when converting local recurrence timestamps to instants in several places, which returns None (and thus aborts the whole expansion via ?) whenever a local time is ambiguous, such as during a DST fall-back overlap. That matches the behavior you describe: a legitimate Auckland local-time recurrence silently yields zero expanded occurrences for the JMAP CalendarEvent/query call once the series crosses the fall-back transition.

Your proposed fix, using .earliest() to pick the earlier of the two valid instants for an ambiguous local time (and skipping only truly nonexistent times from a spring-forward gap) rather than aborting the whole expansion, looks like a sound and minimal change, and the included regression test targets the exact failure mode.

This is a code-level defect rather than a documentation or configuration question, so there’s nothing in the docs to point you to for a workaround here. A staff member or maintainer will need to review and merge the patch; thank you for including a detailed reproduction and diff, that will make triage much faster.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

Fixed in v0.16.14 (not yet released).