Files
webmail-client/plugins/webmail_plugin_calendar/webmail_plugin_calendar/recurrence.py
T
lukasandClaude Opus 4.8 8c634f7967 M4: Calendar plugin (builtin) + mail HTML/attachment fixes
Calendar plugin (webmail_plugin_calendar, non-removable core plugin):
- Local calendars/events CRUD with RLS isolation; recurrence expanded at
  query time (DST-correct), EXDATE + detached-override merge.
- Calendar app bundle: month/week/day/3-day views, mini-month, event form
  (color, show-as, timezone combobox, repeat, reminders, guests w/ recipient
  autocomplete, Jitsi video call), event popover with RSVP badges, resizable/
  collapsible left panel + theme toggle, overlap layout.
- iMIP invites: sdk.mail sends REQUEST from the organizer's mailbox (HTML +
  text/calendar + invite.ics); public /rsvp endpoint (vault-sealed token)
  records accepted/declined/tentative per attendee.
- "This and following" edit + scoped delete (this / this-and-following), with
  a delete confirmation dialog for recurring events.

Core:
- Settings: time_format (12/24h, default 24h) + jitsi_server; new Integrations
  section; date order follows time format (en-GB vs en-US) across Mail.
- Plugin SDK: mail (send from a user's account), vault (envelope encrypt),
  multi-method SSRF-guarded http; AppSwitcher product switcher; full-bleed
  PluginPage.

Mail HTML rendering:
- Sanitizer allows <style> blocks + class (CSS sanitized: drop script-like,
  strip @import/url() when remote images blocked); SANITIZER_VERSION 3 so
  already-indexed mail re-sanitizes on view.
- Attachments: a Content-Disposition: attachment part is a real attachment
  even when it also carries a Content-ID (fixes hidden invite.ics).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:33:34 +02:00

82 lines
3.1 KiB
Python

"""Recurrence expansion (SPEC §A.3).
Events store ``dtstart`` in UTC plus the original ``tz``. RRULEs are expanded at
query time (no materialization) over the requested range only. DST correctness:
a weekly "Monday 10:00 Europe/Warsaw" event stays at 10:00 *local* on both sides of
a DST change (its UTC offset shifts). dateutil computes occurrences by naive
arithmetic, so we expand on the wall-clock time in the event's own zone, then
localize each occurrence back to UTC — that keeps the local time fixed and lets the
offset move. Iterations are capped per event; EXDATEs remove instances. Detached
overrides (``recurrence_id`` rows) are merged by the caller, not here.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from itertools import islice
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr
MAX_INSTANCES = 1000
def _localize_until(rrule: str, zone: ZoneInfo) -> str:
"""Rewrite a UTC (``…Z``) ``UNTIL`` to naive wall-clock in ``zone``.
We expand on naive wall-clock (see module docstring), and dateutil requires
``UNTIL`` to match ``DTSTART``'s awareness — so a standards-compliant UTC UNTIL
must be converted to the same naive local clock the expansion runs in.
"""
parts = []
for part in rrule.split(";"):
if part.startswith("UNTIL=") and part.endswith("Z"):
try:
dt = datetime.strptime(part[6:-1], "%Y%m%dT%H%M%S").replace(tzinfo=UTC)
part = "UNTIL=" + dt.astimezone(zone).replace(tzinfo=None).strftime("%Y%m%dT%H%M%S")
except ValueError:
pass
parts.append(part)
return ";".join(parts)
def expand_occurrences(
*,
dtstart: datetime,
duration: timedelta,
rrule: str | None,
exdates: list[datetime],
range_start: datetime,
range_end: datetime,
tz: str,
all_day: bool = False,
) -> list[tuple[datetime, datetime]]:
"""Return ``(start, end)`` occurrences (UTC, aware) overlapping ``[range_start,
range_end)``. All datetimes in/out are timezone-aware."""
# All-day events are date-anchored — expand in UTC so they don't shift.
zone = ZoneInfo("UTC") if all_day else ZoneInfo(tz or "UTC")
local_start = dtstart.astimezone(zone).replace(tzinfo=None)
def to_utc(naive: datetime) -> datetime:
return naive.replace(tzinfo=zone).astimezone(UTC)
excluded = {e.astimezone(UTC) for e in exdates}
if rrule:
lo = range_start.astimezone(zone).replace(tzinfo=None) - duration
hi = range_end.astimezone(zone).replace(tzinfo=None)
rule = rrulestr(_localize_until(rrule, zone), dtstart=local_start)
naive_starts = list(islice(rule.between(lo, hi, inc=True), MAX_INSTANCES))
else:
naive_starts = [local_start]
out: list[tuple[datetime, datetime]] = []
for naive in naive_starts:
start = to_utc(naive)
if start in excluded:
continue
end = start + duration
if end > range_start and start < range_end: # overlaps the window
out.append((start, end))
return out