fix(calendar): keep recurring events with a UTC UNTIL from collapsing to one (#1383)

Events are stored with a naive (UTC) dtstart, but standard .ics exporters
(Google, Apple, Outlook, Fastmail) write the recurrence bound as an absolute
UTC value, e.g. FREQ=DAILY;UNTIL=20240105T090000Z. dateutil refuses to mix a
tz-aware UNTIL with a naive DTSTART ("RRULE UNTIL values must be specified in
UTC when DTSTART is timezone-aware"), so _expand_rrule's except branch swallowed
the ValueError and silently downgraded the event to non-recurring — every
occurrence after the first vanished from the calendar.

When dtstart is naive, strip the trailing Z from UNTIL so it matches the naive
DTSTART before parsing. No effect on tz-aware dtstarts or naive-UNTIL rules.

Adds tests/test_calendar_rrule_until_utc.py — a daily series bounded by a UTC
UNTIL expands to all 5 occurrences (fails before: returns 1, non-recurring).

Co-authored-by: NubsCarson <nubs@nubs.site>
This commit is contained in:
Shaw
2026-06-03 01:24:14 -04:00
committed by GitHub
parent fb8a744cae
commit bfbbc9b479
2 changed files with 87 additions and 1 deletions
+14 -1
View File
@@ -470,8 +470,21 @@ def _expand_rrule(
return [d]
# Parse the rrule, applying it to the base dtstart.
rrule_str = ev.rrule
if ev.dtstart is not None and getattr(ev.dtstart, "tzinfo", None) is None:
# Events are stored with a naive (UTC) dtstart, but standard .ics
# exporters (Google/Apple/Outlook/Fastmail) write the bound as an
# absolute UTC value, e.g. UNTIL=20240105T090000Z. dateutil refuses to
# mix a tz-aware UNTIL with a naive DTSTART ("RRULE UNTIL values must be
# specified in UTC when DTSTART is timezone-aware"), so the except branch
# below would silently collapse the whole series to a single event.
# Drop the trailing Z so UNTIL matches the naive DTSTART.
import re as _re
rrule_str = _re.sub(
r"(UNTIL=\d{8}(?:T\d{6})?)Z", r"\1", rrule_str, flags=_re.IGNORECASE
)
try:
rule = rrulestr(ev.rrule, dtstart=ev.dtstart)
rule = rrulestr(rrule_str, dtstart=ev.dtstart)
except Exception as ex:
logger.warning(
"Failed to parse rrule=%r for event %s: %s", ev.rrule, ev.uid, ex