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.
7.0 KiB
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
- User adds attendees via
AttendeesSectionin EventModal useEventForm.toIcsEvent()serializes the event withATTENDEEandORGANIZERpropertiesCalDavService.createEvent()sends a PUT to CalDAV through the Django proxy- The proxy (
CalDAVProxyView) injects anX-CalDAV-Callback-URLheader pointing back to Django
The resulting .ics contains:
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:
- CalendarSanitizerPlugin (priority 85) — strips inline binary attachments (Outlook signatures), truncates oversized fields, enforces max resource size (1 MB default)
- AttendeeNormalizerPlugin (priority 90) — lowercases emails, deduplicates attendees keeping the highest-priority PARTSTAT (ACCEPTED > TENTATIVE > DECLINED > NEEDS-ACTION)
- 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:
- Parse —
ICalendarParser.parse()extracts UID, summary, dates, organizer, attendee, location, description, sequence number - Template selection based on method and sequence:
Method Sequence Template REQUEST 0 calendar_invitation.htmlREQUEST >0 calendar_invitation_update.htmlCANCEL any calendar_invitation_cancel.htmlREPLY any calendar_invitation_reply.html - RSVP tokens — for REQUEST emails, generates signed URLs:
Tokens are signed with
/rsvp/?token=<signed>&action=accepted /rsvp/?token=<signed>&action=tentative /rsvp/?token=<signed>&action=declineddjango.core.signing.Signer(salt="rsvp")and contain{uid, email, organizer}. - ICS attachment — if
CALENDAR_ITIP_ENABLED=True, the attachment includesMETHOD:REQUESTfor iTIP-aware clients (Outlook, Apple Mail). If False (default), the METHOD is stripped and web RSVP links are used instead. - 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:
- Unsigns the token (salt="rsvp")
- Finds the event in the organizer's CalDAV calendar by UID
- Checks the event is not in the past (recurring events are never considered past)
- Updates the attendee's
PARTSTATto ACCEPTED / TENTATIVE / DECLINED - PUTs the updated event back to CalDAV
- 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:
CalDavService.updateEvent()increments theSEQUENCEnumber- SabreDAV detects the change and creates REQUEST messages with the updated sequence
- Attendees receive an update email
(
calendar_invitation_update.html)
Cancelling an event
When an event with attendees is deleted:
- SabreDAV creates CANCEL messages for each attendee
- 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/calendarattachment withMETHOD: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.