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>
82 lines
3.1 KiB
Python
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
|