Files
Calendarr/backend/ical_io.py
T
ScarriffleandClaude Opus 5 9bf7d3797d Take in invitations that arrive by mail
A watched mailbox is a trust boundary this app did not have: anyone who knows
the address can hand the server an iCalendar file. So a mail can do exactly one
thing — create a row in event_invites. No calendar read path touches that
table, and an invitation becomes an event only when a person accepts it.

Routing is by the ATTENDEE address, never the From header, which a sender can
claim freely. Addresses are matched against users.email plus a dedicated table,
so forwarded mail still finds its owner: the invitation names the original
address, not the mailbox it landed in.

An accepted invitation gets a fresh UID; the organiser's goes to external_uid.
LocalEvent.uid is globally unique, so reusing it would let a stranger's mail
collide with an event already there. That case has a test, because it is the
one that would be a real problem.

parse_ics now reads METHOD, SEQUENCE, the organiser address, the ATTENDEE list
and RECURRENCE-ID. All additive — existing callers read the keys they knew.
METHOD sits on the VCALENDAR, so it is read outside the component loop; that is
the only reliable way to tell an invitation from a plain calendar file. A VEVENT
carrying RECURRENCE-ID is skipped rather than applied, since treating one
occurrence as the series would move every other date.

The mailbox job registers only when IMAP_HOST is set: an unconditional job would
log every few minutes that it has nothing to do, and test_scheduler asserts the
exact job set. Credentials come from the environment, which keeps the one secret
that opens a whole mailbox out of a table an endpoint could return.

Replies to the organiser are the next step; accepting and declining work without
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-29 17:16:25 +01:00

288 lines
11 KiB
Python

"""iCal (.ics) import/export for local calendars.
Reuses the already-installed ``icalendar`` library. The parser produces dicts
matching the LocalEvent storage shape (ISO strings, comma-separated EXDATE);
the generator emits a VCALENDAR with ORGANIZER, RRULE, etc.
"""
from __future__ import annotations
import logging
import uuid
from urllib.parse import quote
from datetime import date, datetime, timedelta, timezone
from icalendar import Calendar, Event, vCalAddress, vRecur, vText, vUri
from icalendar.parser import Parameters
logger = logging.getLogger(__name__)
def _rrule_to_str(component) -> str | None:
prop = component.get("RRULE")
if not prop:
return None
return prop.to_ical().decode("utf-8")
def _exdate_to_csv(component) -> str | None:
"""Collect EXDATE values as comma-separated YYYYMMDD strings."""
exdate = component.get("EXDATE")
if not exdate:
return None
items = exdate if isinstance(exdate, list) else [exdate]
out = []
for ex in items:
dts = getattr(ex, "dts", None) or []
for d in dts:
val = d.dt
if isinstance(val, datetime):
out.append(val.strftime("%Y%m%d"))
elif isinstance(val, date):
out.append(val.strftime("%Y%m%d"))
return ",".join(out) if out else None
def _organizer_name(component) -> str | None:
org = component.get("ORGANIZER")
if not org:
return None
# CN parameter holds the display name; fall back to the mailto address.
try:
cn = org.params.get("CN")
if cn:
return str(cn)
except Exception:
pass
raw = str(org)
if raw.lower().startswith("mailto:"):
return raw[7:]
return raw or None
def _cal_address(prop) -> str | None:
"""The bare lowercase address of an ORGANIZER/ATTENDEE property."""
if prop is None:
return None
raw = str(prop).strip()
if raw.lower().startswith("mailto:"):
raw = raw[7:]
raw = raw.strip().strip("<>").lower()
return raw or None
def _attendees(component) -> list[dict]:
"""The ATTENDEE list with the parameters an iTIP reply needs.
icalendar hands back a single property when there is one attendee and a
list when there are several, so both shapes have to be normalised.
"""
raw = component.get("ATTENDEE")
if raw is None:
return []
items = raw if isinstance(raw, list) else [raw]
out = []
for prop in items:
email = _cal_address(prop)
if not email:
continue
params = getattr(prop, "params", {}) or {}
out.append({
"email": email,
"cn": str(params.get("CN")) if params.get("CN") else None,
"partstat": str(params.get("PARTSTAT") or "NEEDS-ACTION").upper(),
"role": str(params.get("ROLE") or "REQ-PARTICIPANT").upper(),
"rsvp": str(params.get("RSVP") or "").upper() == "TRUE",
})
return out
def parse_ics(raw: bytes) -> dict:
"""Parse .ics bytes into {"method": str|None, "events": [...], "errors": [...]}.
``method`` sits on the VCALENDAR, not on a VEVENT, so it is read outside the
component loop — it is the only reliable way to tell an invitation
(METHOD:REQUEST) from a plain calendar file. Every key added for iTIP is
additive: existing callers read the ones they already knew.
Raises ValueError if the payload is not a parseable calendar at all.
"""
try:
cal = Calendar.from_ical(raw)
except Exception as e:
raise ValueError(f"Datei ist kein gültiges iCal-Format: {e}") from e
events = []
errors = []
for component in cal.walk():
if component.name != "VEVENT":
continue
try:
uid = str(component.get("UID") or uuid.uuid4())
title = str(component.get("SUMMARY", "") or "")
location = str(component.get("LOCATION", "") or "") or None
description = str(component.get("DESCRIPTION", "") or "") or None
dtstart_prop = component.get("DTSTART")
if dtstart_prop is None:
errors.append(f"VEVENT {uid}: kein DTSTART, übersprungen")
continue
dtstart = dtstart_prop.dt
dtend_prop = component.get("DTEND")
duration_prop = component.get("DURATION")
all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime)
if all_day:
if dtend_prop:
dtend = dtend_prop.dt
elif duration_prop:
dtend = dtstart + duration_prop.dt
else:
dtend = dtstart + timedelta(days=1)
start_str = dtstart.isoformat()
end_str = (dtend.isoformat() if isinstance(dtend, date)
else (dtstart + timedelta(days=1)).isoformat())
else:
if dtstart.tzinfo is None:
dtstart = dtstart.replace(tzinfo=timezone.utc)
if dtend_prop:
dtend = dtend_prop.dt
if isinstance(dtend, date) and not isinstance(dtend, datetime):
dtend = datetime.combine(dtend, datetime.min.time(), tzinfo=timezone.utc)
elif dtend.tzinfo is None:
dtend = dtend.replace(tzinfo=timezone.utc)
elif duration_prop:
dtend = dtstart + duration_prop.dt
else:
dtend = dtstart + timedelta(hours=1)
start_str = dtstart.isoformat()
end_str = dtend.isoformat()
events.append({
"uid": uid,
"title": title,
"start": start_str,
"end": end_str,
"all_day": all_day,
"location": location,
"description": description,
"rrule": _rrule_to_str(component),
"exdate": _exdate_to_csv(component),
"organizer": _organizer_name(component),
"organizer_email": _cal_address(component.get("ORGANIZER")),
"attendees": _attendees(component),
"sequence": int(component.get("SEQUENCE", 0) or 0),
# A single occurrence of a series. Applying one as if it were
# the whole series would silently replace every other date.
"recurrence_id": (str(component.get("RECURRENCE-ID").dt)
if component.get("RECURRENCE-ID") is not None else None),
})
except Exception as exc:
logger.warning("Skipping malformed VEVENT: %s", exc)
errors.append(f"Fehlerhafter Eintrag übersprungen: {exc}")
method = str(cal.get("METHOD") or "").upper() or None
return {"method": method, "events": events, "errors": errors}
def _rrule_str_to_vrecur(rrule_str: str) -> vRecur:
"""Parse an RRULE string into a vRecur with correctly typed values.
Hand-splitting on ";" and "=" leaves every value a str, which icalendar
accepts silently here and then rejects inside ``to_ical()``: vRecur.types
maps UNTIL to vDDDTypes, and that demands a date. A single "repeats until
<date>" event therefore made the whole calendar fail to serialise — a 500
on REPORT, on GET and on export, while PROPFIND kept working because it
never builds ICS. Letting icalendar parse its own format gives UNTIL as a
datetime and COUNT/INTERVAL as ints.
"""
return vRecur(vRecur.from_ical(rrule_str))
def _parse_iso(s: str) -> datetime:
s = s.replace("Z", "+00:00")
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def build_ics(calendar, events, *, name_cache: dict | None = None,
attachments_by_event: dict | None = None,
base_url: str | None = None) -> str:
"""Build a VCALENDAR string for a local calendar and its events.
``attachments_by_event`` maps LocalEvent.id -> list of EventAttachment. With
a ``base_url`` each one becomes an ATTACH property pointing at its
capability URL, so external clients (Apple Calendar, Thunderbird) can fetch
it — they cannot send a bearer token. Both arguments are optional so every
existing caller keeps working and emits no ATTACH at all.
"""
cal = Calendar()
cal.add("prodid", "-//Calendarr//EN")
cal.add("version", "2.0")
cal.add("x-wr-calname", calendar.name)
for ev in events:
item = Event()
item.add("uid", ev.uid)
item.add("summary", ev.title or "")
item.add("dtstamp", datetime.now(timezone.utc))
if ev.all_day:
try:
start = date.fromisoformat(ev.start[:10])
end = date.fromisoformat(ev.end[:10])
except ValueError:
continue
if end <= start:
end = start + timedelta(days=1)
item.add("dtstart", start)
item.add("dtend", end)
else:
try:
item.add("dtstart", _parse_iso(ev.start))
item.add("dtend", _parse_iso(ev.end))
except ValueError:
continue
if ev.location:
item.add("location", ev.location)
if ev.description:
item.add("description", ev.description)
if ev.color:
item.add("x-calendarr-color", ev.color)
if ev.rrule:
try:
item.add("rrule", _rrule_str_to_vrecur(ev.rrule))
except (ValueError, TypeError):
logger.warning("Skipping unparsable RRULE %r on event %s", ev.rrule, ev.id)
# ORGANIZER from the creator (local user or imported name).
organizer_name = None
if getattr(ev, "creator_id", None) and name_cache:
organizer_name = name_cache.get(ev.creator_id)
if not organizer_name:
organizer_name = getattr(ev, "creator_name_external", None)
if organizer_name:
organizer = vCalAddress("mailto:noreply@calendarr.local")
organizer.params["CN"] = vText(organizer_name.replace('"', ""))
item.add("organizer", organizer)
# ATTACH: one line per attachment, as a URI rather than inline base64 —
# the files are up to 10 MB each and would otherwise be copied into
# every ICS body on every sync.
for att in (attachments_by_event or {}).get(getattr(ev, "id", None), ()):
if not base_url:
continue # a relative ATTACH URL is useless to an external client
uri = vUri(f"{base_url}/api/attach/{att.token}/{quote(att.filename)}")
uri.params = Parameters({
"FMTTYPE": att.content_type,
"SIZE": str(att.size_bytes),
"FILENAME": att.filename,
})
item.add("attach", uri, encode=0)
cal.add_component(item)
return cal.to_ical().decode("utf-8")