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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
aa795fde76
commit
9bf7d3797d
+52
-2
@@ -60,8 +60,50 @@ def _organizer_name(component) -> str | None:
|
||||
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 {"events": [dict, ...], "errors": [str, ...]}.
|
||||
"""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.
|
||||
"""
|
||||
@@ -127,11 +169,19 @@ def parse_ics(raw: bytes) -> dict:
|
||||
"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}")
|
||||
return {"events": events, "errors": errors}
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user