Files
calendars/docs/invitations.md
Sylvain Zimmer 9c18f96090 (all) add organizations, resources, channels, and infra migration (#34)
Add multi-tenant organization model populated from OIDC claims with
org-scoped user discovery, CalDAV principal filtering, and cross-org
isolation at the SabreDAV layer.

Add bookable resource principals (rooms, equipment) with CalDAV
auto-scheduling that handles conflict detection, auto-accept/decline,
and org-scoped booking enforcement. Fixes #14.

Replace CalendarSubscriptionToken with a unified Channel model
supporting CalDAV integration tokens and iCal feed URLs, with
encrypted token storage and role-based access control. Fixes #16.

Migrate task queue from Celery to Dramatiq with async ICS import,
progress tracking, and task status polling endpoint.

Replace nginx with Caddy for both the reverse proxy and frontend
static serving. Switch frontend package manager from yarn/pnpm to
npm and upgrade Node to 24, Next.js to 16, TypeScript to 5.9.

Harden security with fail-closed entitlements, RSVP rate limiting
and token expiry, CalDAV proxy path validation blocking internal
API routes, channel path scope enforcement, and ETag-based
conflict prevention.

Add frontend pages for resource management and integration channel
CRUD, with resource booking in the event modal.

Restructure CalDAV paths to /calendars/users/ and
/calendars/resources/ with nested principal collections in SabreDAV.
2026-03-09 09:09:34 +01:00

196 lines
7.0 KiB
Markdown

# Invitations
How event invitations work end-to-end: creating, sending, responding,
updating, and cancelling.
## Architecture
```
Frontend (EventModal)
→ CalDAV proxy (Django)
→ SabreDAV (stores event, detects attendees)
→ HttpCallbackIMipPlugin (HTTP POST to Django)
→ CalendarInvitationService (sends email)
→ Attendee receives email
→ RSVP link or iTIP client response
→ RSVPView (Django) or CalDAV REPLY
→ PARTSTAT updated in event
→ Organizer notified
```
## Creating an event with attendees
1. User adds attendees via `AttendeesSection` in EventModal
2. `useEventForm.toIcsEvent()` serializes the event with `ATTENDEE`
and `ORGANIZER` properties
3. `CalDavService.createEvent()` sends a PUT to CalDAV through the
Django proxy
4. The proxy (`CalDAVProxyView`) injects an
`X-CalDAV-Callback-URL` header pointing back to Django
The resulting `.ics` contains:
```ics
BEGIN:VEVENT
UID:abc-123
SUMMARY:Team Meeting
DTSTART:20260301T140000Z
DTEND:20260301T150000Z
ORGANIZER;CN=Alice:mailto:alice@example.com
ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:bob@example.com
SEQUENCE:0
END:VEVENT
```
## SabreDAV processing
When SabreDAV receives the event, three plugins run in order:
1. **CalendarSanitizerPlugin** (priority 85) — strips inline binary
attachments (Outlook signatures), truncates oversized fields,
enforces max resource size (1 MB default)
2. **AttendeeNormalizerPlugin** (priority 90) — lowercases emails,
deduplicates attendees keeping the highest-priority PARTSTAT
(ACCEPTED > TENTATIVE > DECLINED > NEEDS-ACTION)
3. **iMip scheduling** — detects attendees and creates a REQUEST
message for each one
The scheduling message is routed by **HttpCallbackIMipPlugin**, which
POSTs to Django:
```
POST /api/v1.0/caldav-scheduling-callback/
X-Api-Key: <shared secret>
X-CalDAV-Sender: alice@example.com
X-CalDAV-Recipient: bob@example.com
X-CalDAV-Method: REQUEST
Content-Type: text/calendar
<serialized VCALENDAR>
```
## Sending invitation emails
`CalDAVSchedulingCallbackView` receives the callback and delegates to
`CalendarInvitationService.send_invitation()`.
Steps:
1. **Parse**`ICalendarParser.parse()` extracts UID, summary,
dates, organizer, attendee, location, description, sequence number
2. **Template selection** based on method and sequence:
| Method | Sequence | Template |
|--------|----------|----------|
| REQUEST | 0 | `calendar_invitation.html` |
| REQUEST | >0 | `calendar_invitation_update.html` |
| CANCEL | any | `calendar_invitation_cancel.html` |
| REPLY | any | `calendar_invitation_reply.html` |
3. **RSVP tokens** — for REQUEST emails, generates signed URLs:
```
/rsvp/?token=<signed>&action=accepted
/rsvp/?token=<signed>&action=tentative
/rsvp/?token=<signed>&action=declined
```
Tokens are signed with `django.core.signing.Signer(salt="rsvp")`
and contain `{uid, email, organizer}`.
4. **ICS attachment** — if `CALENDAR_ITIP_ENABLED=True`, the
attachment includes `METHOD:REQUEST` for iTIP-aware clients
(Outlook, Apple Mail). If False (default), the METHOD is stripped
and web RSVP links are used instead.
5. **Send** — multipart email with HTML + plain text + ICS attachment.
Reply-To is set to the organizer's email.
## Responding to invitations
Two paths:
### Web RSVP (default)
Attendee clicks Accept / Maybe / Decline link in the email.
`RSVPView` handles `GET /rsvp/?token=...&action=accepted`:
1. Unsigns the token (salt="rsvp")
2. Finds the event in the organizer's CalDAV calendar by UID
3. Checks the event is not in the past (recurring events are never
considered past)
4. Updates the attendee's `PARTSTAT` to ACCEPTED / TENTATIVE / DECLINED
5. PUTs the updated event back to CalDAV
6. Renders a confirmation page
The PUT triggers SabreDAV to generate a REPLY message, which flows
back through HttpCallbackIMipPlugin → Django → organizer email.
### iTIP client response
When `CALENDAR_ITIP_ENABLED=True`, email clients like Outlook or
Apple Calendar show native Accept/Decline buttons. The client sends
an iTIP REPLY directly to the CalDAV server, which triggers the same
callback flow.
## Updating an event
When an event with attendees is modified:
1. `CalDavService.updateEvent()` increments the `SEQUENCE` number
2. SabreDAV detects the change and creates REQUEST messages with the
updated sequence
3. Attendees receive an update email
(`calendar_invitation_update.html`)
## Cancelling an event
When an event with attendees is deleted:
1. SabreDAV creates CANCEL messages for each attendee
2. Attendees receive a cancellation email
(`calendar_invitation_cancel.html`)
## Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| `CALDAV_URL` | `http://caldav:80` | Internal CalDAV server URL |
| `CALDAV_INBOUND_API_KEY` | None | API key for callbacks from CalDAV |
| `CALDAV_OUTBOUND_API_KEY` | None | API key for requests to CalDAV |
| `CALDAV_CALLBACK_BASE_URL` | None | Internal URL for CalDAV→Django (Docker: `http://backend:8000`) |
| `CALENDAR_ITIP_ENABLED` | False | Use iTIP METHOD headers in ICS attachments |
| `CALENDAR_INVITATION_FROM_EMAIL` | `DEFAULT_FROM_EMAIL` | Sender address for invitation emails |
| `APP_URL` | `""` | Base URL for RSVP links in emails |
## Key files
| Area | Path |
|------|------|
| Attendee UI | `src/frontend/.../event-modal-sections/AttendeesSection.tsx` |
| Event form | `src/frontend/.../scheduler/hooks/useEventForm.ts` |
| CalDAV client | `src/frontend/.../services/dav/CalDavService.ts` |
| CalDAV proxy | `src/backend/core/api/viewsets_caldav.py` |
| Scheduling callback | `src/backend/core/api/viewsets_caldav.py` (`CalDAVSchedulingCallbackView`) |
| RSVP handler | `src/backend/core/api/viewsets_rsvp.py` |
| Email service | `src/backend/core/services/calendar_invitation_service.py` |
| ICS parser | `src/backend/core/services/calendar_invitation_service.py` (`ICalendarParser`) |
| Email templates | `src/backend/core/templates/emails/calendar_invitation*.html` |
| SabreDAV sanitizer | `src/caldav/src/CalendarSanitizerPlugin.php` |
| SabreDAV attendee dedup | `src/caldav/src/AttendeeNormalizerPlugin.php` |
| SabreDAV callback plugin | `src/caldav/src/HttpCallbackIMipPlugin.php` |
## Future: Messages mail client integration
La Suite includes a Messages mail client (based on an open-source
webmail). Future integration would allow:
- **Inline RSVP** — render Accept/Decline buttons directly in the
Messages UI when an email contains a `text/calendar` attachment with
`METHOD:REQUEST`
- **Calendar preview** — show event details (date, time, location)
extracted from the ICS attachment without opening the full calendar
- **Auto-add to calendar** — accepted events automatically appear in
the user's Calendars calendar via a shared CalDAV backend
- **Status sync** — PARTSTAT changes in Messages propagate to
Calendars and vice versa
This requires Messages to support iTIP processing
(`CALENDAR_ITIP_ENABLED=True`) and share the same CalDAV/auth
infrastructure.