Skip to content

✨(calendar) add link to a CalDAV instance to accept events directly#584

Open
sylvinus wants to merge 5 commits intomainfrom
caldav
Open

✨(calendar) add link to a CalDAV instance to accept events directly#584
sylvinus wants to merge 5 commits intomainfrom
caldav

Conversation

@sylvinus
Copy link
Copy Markdown
Member

@sylvinus sylvinus commented Mar 9, 2026

First step in the interop with https://github.com/suitenumerique/calendars

Summary by CodeRabbit

  • New Features

    • RSVP (Accept/Decline/Maybe), "Add to calendar", calendar chooser, and background task submission with progress for mailbox-backed invites.
    • Calendar listing and conflict detection with visible conflict details and a warning when calendar service is unavailable.
  • Style

    • Improved calendar-invite layout, wrapping actions and responsive conflict UI styles.
  • Localization

    • Added English strings for RSVP actions, add-to-calendar, and conflict pluralization.
  • Tests

    • End-to-end and unit tests covering calendar flows and CalDAV integration.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 9, 2026

📝 Walkthrough

Walkthrough

Adds CalDAV integration: a CalDAV client/service, Celery tasks, four authenticated DRF endpoints (list/conflicts/add/rsvp) under mailbox routes, mailbox/channel/instance config support, frontend UI/hook changes to surface calendars/conflicts and enqueue tasks, tests, translations, and a runtime dependency (icalendar).

Changes

Cohort / File(s) Summary
CalDAV service
src/backend/core/services/calendar/service.py
New CalDAVService and CalDAVError: HTTP wrapper, principal/home discovery, list/check/add/respond operations, helpers and factory constructors from channel or instance config.
CalDAV tasks
src/backend/core/services/calendar/tasks.py, src/backend/core/tasks.py
Celery tasks calendar_rsvp_task and calendar_add_event_task; helper to select service by channel or instance config; wildcard import added for autodiscovery.
Backend API + routing
src/backend/core/api/viewsets/calendar.py, src/backend/core/urls.py, src/backend/core/api/openapi.json
New CalDAVChannelMixin and four DRF views: CalendarRsvpView, CalendarAddEventView, CalendarConflictsView, CalendarListView; URL patterns and OpenAPI schema updates for mailbox-scoped calendar endpoints; request/response validation and error responses.
Instance settings
src/backend/messages/settings.py
Added optional CALDAV_DEFAULT_URL and CALDAV_DEFAULT_PASSWORD instance settings.
Backend tests
src/backend/core/tests/api/test_calendar.py
Extensive end-to-end tests using an in-process Radicale server plus unit-style tests for service internals, selection guards, and PARTSTAT logic.
Frontend: calendar invite UI
src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.tsx, .../thread-message/thread-message-footer.tsx, .../_index.scss
Adds mailboxId prop; fetches ICS, calendars, and conflicts; renders conflict warnings, calendar chooser, RSVP and Add-to-calendar flows that enqueue backend tasks; styles for conflicts.
Frontend: hooks & callers
src/frontend/src/hooks/use-task-status.ts, src/frontend/src/features/controlled-modals/message-importer/step-loader.tsx, src/frontend/src/features/layouts/components/main/header/authenticated.tsx
Renames useImportTaskStatususeTaskStatus (new exported TaskMetadata type); updates callers to use the new hook and shapes.
i18n & deps
src/frontend/public/locales/common/en-US.json, src/backend/pyproject.toml
Added translation strings for RSVP/actions and conflict pluralization; added runtime dependency icalendar==7.0.3 and dev optional radicale==3.6.1.
Test fixtures adjustments
src/backend/core/tests/api/test_messages_import.py, src/backend/core/tests/importer/conftest.py
Adjusted SSRF/DNS mocking to patch higher-level hostname validation in import tests.

Sequence Diagram(s)

sequenceDiagram
    participant Browser as Browser
    participant Frontend as CalendarInvite Component
    participant API as DRF Calendar Views
    participant Queue as Celery Task Queue
    participant Worker as Celery Worker
    participant Service as CalDAVService
    participant CalDAV as CalDAV Server

    Browser->>Frontend: Render invite (with mailboxId)
    Frontend->>API: GET /mailboxes/:id/calendar/calendars/
    API->>Service: get_caldav_service()
    Service->>CalDAV: PROPFIND (discover/list)
    CalDAV-->>Service: calendars
    Service-->>API: {calendars}
    API-->>Frontend: {calendars}
    Frontend->>API: POST /mailboxes/:id/calendar/rsvp/ (ics_data, response)
    API->>API: validate request, require_caldav_service()
    API->>Queue: calendar_rsvp_task.delay(...)
    API-->>Frontend: {task_id}
    Queue->>Worker: Execute calendar_rsvp_task
    Worker->>Service: from_channel_or_instance(...)
    Worker->>Service: respond_to_event(...)
    Service->>CalDAV: REPORT/PUT to update event
    CalDAV-->>Service: success
    Worker-->>Queue: task result (SUCCESS/FAILURE)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • sdemagny
  • jbpenrath

Poem

🐰 I hopped through configs, channels, and queues,

I nudged attendees gently—yes, or no, or hues,
I checked for conflicts by moon and sunbeam,
Tasks leapt to celery and calendars gleam,
A rabbit shipped invites with a small happy scheme.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.49% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: adding CalDAV integration to accept calendar events directly, matching the substantial implementation across multiple backend and frontend modules.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch caldav

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss (1)

267-273: Consider layout for three-item conflict rows.

The conflict item uses justify-content: space-between with three children (conflict-summary, conflict-calendar, conflict-time). This distributes items with the summary on the left, calendar in the middle, and time on the right. On narrow viewports, this may cause text truncation or awkward spacing.

Consider using flex-wrap: wrap or adjusting the layout so content can flow more gracefully when space is constrained.

💡 Optional: Allow wrapping for conflict items
 .calendar-invite__conflict-item {
     display: flex;
     justify-content: space-between;
     align-items: center;
     gap: var(--c--globals--spacings--sm);
     padding: var(--c--globals--spacings--3xs) 0;
+    flex-wrap: wrap;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss`
around lines 267 - 273, The .calendar-invite__conflict-item currently uses
justify-content: space-between which spreads .conflict-summary,
.conflict-calendar, and .conflict-time awkwardly on narrow viewports; update
.calendar-invite__conflict-item to allow wrapping (e.g., add flex-wrap: wrap)
and adjust child flex rules so .conflict-summary can shrink/grow (e.g., give it
flex: 1 1 auto and min-width: 0) while keeping .conflict-calendar and
.conflict-time fixed or non-growing (e.g., flex: 0 0 auto), and tweak gaps or
padding as needed so the three-item row flows gracefully on small screens.
src/backend/core/services/calendar/service.py (2)

133-133: Move import re to module level.

The re module is imported inside methods at lines 133 and 157. Moving it to the top of the file alongside other imports improves readability and follows Python conventions.

Proposed fix at module level
 """CalDAV calendar service for RSVP and event management."""
 
 import logging
+import re
 
 import caldav
 from caldav.elements import dav

Then remove the local imports at lines 133 and 157.

Also applies to: 157-157

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/services/calendar/service.py` at line 133, Move the "import
re" statement to the top of the module alongside the other imports (add a single
module-level "import re") and remove the local "import re" statements that
currently appear inside methods in this file; ensure those methods (which use
regex functionality) continue to reference the module-level "re" symbol rather
than importing it locally.

20-28: Add a timeout to DAVClient to prevent indefinite request blocking.

The caldav.DAVClient is created without a timeout parameter. If the CalDAV server is slow or unresponsive, this could cause request threads (in CalendarConflictsView and CalendarListView) or Celery workers to block indefinitely.

Proposed fix
     `@property`
     def client(self):
         if self._client is None:
             self._client = caldav.DAVClient(
                 url=self.url,
                 username=self.username,
                 password=self.password,
+                timeout=30,  # seconds
             )
         return self._client
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/services/calendar/service.py` around lines 20 - 28, The
DAVClient instantiation in the client property lacks a timeout, risking
indefinite blocking; update the client property to pass a sensible timeout value
(e.g., timeout=10 or use a configured constant) into caldav.DAVClient when
creating self._client so requests from CalendarConflictsView/CalendarListView
and Celery workers don't hang indefinitely; ensure the timeout value is
configurable via settings or an environment variable and referenced where
caldav.DAVClient is constructed in the client property.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/backend/core/api/viewsets/calendar.py`:
- Around line 259-267: CalendarListView.get is making blocking I/O via
CalDAVService.from_channel and service.list_calendars (synchronous calls) which
can block the event loop; refactor this handler to offload the blocking calls to
an async-friendly pattern—either convert CalDAVService methods to async or run
the synchronous calls in a thread/process executor (e.g., using
asyncio.to_thread or a background task) before returning the Response;
specifically wrap CalDAVService.from_channel(...) and service.list_calendars()
so they execute off the main thread and handle/propagate exceptions the same way
as existing logger.exception and Response logic.
- Around line 219-229: CalendarConflictsView is performing a blocking call to
the external CalDAV server via CalDAVService.from_channel(...) and
service.check_conflicts(...); change this to avoid blocking the request thread
by either converting the view to an async Django view and awaiting an async
CalDAVService.check_conflicts (ensure CalDAVService implements an async API and
enforces a network timeout), or move the conflict check into a background Celery
task (enqueue a task from CalendarConflictsView that calls
CalDAVService.check_conflicts and return a 202 with a task id), and in either
case ensure CalDAVService has a strict configurable timeout for network calls to
prevent long waits.
- Around line 25-46: The mailbox lookup in CalDAVChannelMixin is missing an
authorization check allowing any authenticated user to access arbitrary
mailboxes; update the cached_property mailbox to resolve the Mailbox through the
same authorized/queryset pattern used in blob.py and contacts.py (i.e., restrict
the queryset to mailboxes the current request.user may access or call the shared
helper that returns an authorized mailbox) so that get_object_or_404 only
returns a mailbox the requester is permitted to use; leave get_caldav_channel
and require_caldav_channel unchanged except that they should rely on the
now-authorized mailbox property.

In
`@src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.tsx`:
- Around line 358-361: The CalDAV query errors are being swallowed and replaced
with empty arrays, making transient failures indistinguishable from genuine "no
data" and hiding UI like the ConflictWarning; update the query hooks used around
the referenced areas (the calendar fetch and event fetch calls used to compute
`conflicts`, `calendars`, and related data) so they propagate an error state
instead of returning `[]` on failure, and adjust the rendering in this component
to check for that error state (e.g., `calendarsError`/`eventsError` or similar)
and render a local "Calendar unavailable — retry" UI with a retry action; ensure
`ConflictWarning` is still shown when `conflicts.length > 0` but that when the
queries report an error you show the unavailable/retry UI instead of collapsing
to an empty state.
- Around line 469-470: The RSVP/Add flow is committing rsvpResponse and relying
on isPending only after the POST returns a task_id, allowing multiple clicks and
lost/incorrect state; add a local "pendingAction" boolean (or enum) state (e.g.,
isSubmitting or pendingRsvp) that is set immediately in the RSVP/Add click
handlers (before the POST) to disable controls, and preserve the intended
response plus the returned task_id in a small pending map (taskId -> intended
response). Keep selectedCalendarId handling but do not call setRsvpResponse
until the background poll for that task_id reaches SUCCESS; on FAILURE/timeout
clear the pendingAction and do not commit rsvpResponse (and re-enable controls).
Update the RSVP/Add handler functions and the polling completion logic to look
up the pending map by task_id and commit or clear state accordingly so clicks
cannot enqueue multiple jobs or prematurely highlight a choice.

---

Nitpick comments:
In `@src/backend/core/services/calendar/service.py`:
- Line 133: Move the "import re" statement to the top of the module alongside
the other imports (add a single module-level "import re") and remove the local
"import re" statements that currently appear inside methods in this file; ensure
those methods (which use regex functionality) continue to reference the
module-level "re" symbol rather than importing it locally.
- Around line 20-28: The DAVClient instantiation in the client property lacks a
timeout, risking indefinite blocking; update the client property to pass a
sensible timeout value (e.g., timeout=10 or use a configured constant) into
caldav.DAVClient when creating self._client so requests from
CalendarConflictsView/CalendarListView and Celery workers don't hang
indefinitely; ensure the timeout value is configurable via settings or an
environment variable and referenced where caldav.DAVClient is constructed in the
client property.

In
`@src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss`:
- Around line 267-273: The .calendar-invite__conflict-item currently uses
justify-content: space-between which spreads .conflict-summary,
.conflict-calendar, and .conflict-time awkwardly on narrow viewports; update
.calendar-invite__conflict-item to allow wrapping (e.g., add flex-wrap: wrap)
and adjust child flex rules so .conflict-summary can shrink/grow (e.g., give it
flex: 1 1 auto and min-width: 0) while keeping .conflict-calendar and
.conflict-time fixed or non-growing (e.g., flex: 0 0 auto), and tweak gaps or
padding as needed so the three-item row flows gracefully on small screens.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a7bfb200-20b4-433f-ad17-21d8b2b39b4e

📥 Commits

Reviewing files that changed from the base of the PR and between dc52f1f and 924321e.

⛔ Files ignored due to path filters (1)
  • src/backend/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • src/backend/core/api/viewsets/calendar.py
  • src/backend/core/services/calendar/__init__.py
  • src/backend/core/services/calendar/service.py
  • src/backend/core/services/calendar/tasks.py
  • src/backend/core/tasks.py
  • src/backend/core/urls.py
  • src/backend/pyproject.toml
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/src/features/controlled-modals/message-importer/step-loader.tsx
  • src/frontend/src/features/layouts/components/main/header/authenticated.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss
  • src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-footer.tsx
  • src/frontend/src/hooks/use-task-status.ts

Comment thread src/backend/core/api/viewsets/calendar.py Outdated
Comment thread src/backend/core/api/viewsets/calendar.py Outdated
Comment thread src/backend/core/api/viewsets/calendar.py
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/backend/core/api/viewsets/calendar.py`:
- Around line 97-129: The view currently forwards calendar_id from post into
calendar_rsvp_task/calendar_add_event_task without verifying it belongs to the
mailbox; instead of adding validation here, implement strict ownership
validation inside the calendar service by updating _pick_calendar_url in
src/backend/core/services/calendar/service.py to resolve/verify that a provided
calendar_id (or URL) is actually part of the requester’s calendar-home-set
(raise an error or return None on mismatch), and ensure calendar_rsvp_task and
calendar_add_event_task call that service helper (or surface the validation
error) so the view (post) can safely forward calendar_id knowing the service
enforces ownership.

In `@src/backend/core/services/calendar/service.py`:
- Around line 218-268: The _pick_calendar_url implementation currently returns
caller-supplied calendar_id verbatim, allowing authenticated PUTs to arbitrary
URLs; change _pick_calendar_url to validate any provided calendar_id against the
set returned by list_calendars() (or the parsed IDs from each calendar dict) and
only use it if it exactly equals one of those known calendar URLs (and
optionally ensure its origin matches self.home_set/origin); if it does not
match, fall back to selecting the first calendar from list_calendars() or raise
CalDAVError. Update callers (respond_to_event / add_event flow that call
_pick_calendar_url before _put_event) to rely on this validated URL so
_put_event never issues authenticated requests to attacker-provided hosts.
- Around line 246-260: The matching logic in _update_partstat incorrectly uses
substring containment (email_lower in str(att).lower()) which can match sibling
addresses; change it to parse and compare the attendee's canonical email address
for exact equality (e.g., extract the address from the vCalAddress/ATTENDEE
value by stripping any "mailto:"/scheme and lowercasing, or use the library's
property that returns the email) and only update when equal, keeping the rest of
the loop (comp walk, attendees normalization, setting att.params["PARTSTAT"],
and popping RSVP) the same; add a regression test TestUpdatePartstat in
test_calendar.py that creates two attendees where one address is a substring of
the other and asserts only the exact match is updated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3b03b3fe-934a-4af2-a929-82681362e162

📥 Commits

Reviewing files that changed from the base of the PR and between 924321e and f349129.

⛔ Files ignored due to path filters (1)
  • src/backend/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (15)
  • src/backend/core/api/viewsets/calendar.py
  • src/backend/core/services/calendar/__init__.py
  • src/backend/core/services/calendar/service.py
  • src/backend/core/services/calendar/tasks.py
  • src/backend/core/tasks.py
  • src/backend/core/tests/api/test_calendar.py
  • src/backend/core/urls.py
  • src/backend/messages/settings.py
  • src/backend/pyproject.toml
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/src/features/controlled-modals/message-importer/step-loader.tsx
  • src/frontend/src/features/layouts/components/main/header/authenticated.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss
  • src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-footer.tsx
✅ Files skipped from review due to trivial changes (3)
  • src/frontend/public/locales/common/en-US.json
  • src/backend/messages/settings.py
  • src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-footer.tsx
  • src/backend/pyproject.toml
  • src/backend/core/tasks.py
  • src/frontend/src/features/controlled-modals/message-importer/step-loader.tsx
  • src/backend/core/urls.py
  • src/backend/core/services/calendar/tasks.py

Comment thread src/backend/core/api/viewsets/calendar.py
Comment thread src/backend/core/services/calendar/service.py
Comment thread src/backend/core/services/calendar/service.py
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/backend/core/services/calendar/service.py (2)

193-198: Minor: findtext called twice per response.

The list comprehension evaluates findtext(...) twice for every matching response (once in the filter, once in the expression). For large calendar result sets this doubles XML tree traversal. A simple loop (or walrus) keeps it single-pass:

Proposed fix
-        root = ET.fromstring(resp.text)
-        return [
-            r.findtext(f".//{_q(CALDAV_NS, 'calendar-data')}") or ""
-            for r in root.findall(_q(DAV_NS, "response"))
-            if r.findtext(f".//{_q(CALDAV_NS, 'calendar-data')}")
-        ]
+        root = ET.fromstring(resp.text)
+        results = []
+        tag = f".//{_q(CALDAV_NS, 'calendar-data')}"
+        for r in root.findall(_q(DAV_NS, "response")):
+            data = r.findtext(tag)
+            if data:
+                results.append(data)
+        return results
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/services/calendar/service.py` around lines 193 - 198, The
list comprehension calls r.findtext(...) twice per response; change it to a
single-pass by iterating over root.findall(_q(DAV_NS, "response")) and storing
the calendar-data result in a local variable (or use the walrus operator) once
per response (use the same _q(CALDAV_NS, 'calendar-data') key), then append only
non-empty values to the result list and return that list from the function where
ET.fromstring(resp.text) and root.findall are used.

32-36: Naive datetime silently treated as UTC.

If a caller passes a tz-naive datetime, the if dt.tzinfo is not None branch is skipped and strftime("…Z") stamps it as UTC regardless of what timezone it actually represented. With Django's USE_TZ=True this is usually fine, but a bug upstream (e.g. a datetime.now() slipping in from a view/serializer) will silently produce wrong CalDAV time-range queries rather than fail loudly.

Consider either asserting aware input or assuming a documented timezone explicitly:

Proposed fix
 def _format_utc(dt):
-    if dt.tzinfo is not None:
-        dt = dt.astimezone(timezone.utc)
-    return dt.strftime("%Y%m%dT%H%M%SZ")
+    if dt.tzinfo is None:
+        raise ValueError("_format_utc requires a timezone-aware datetime")
+    return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/services/calendar/service.py` around lines 32 - 36, The
function _format_utc currently treats tz-naive datetimes as UTC; change it to
require timezone-aware input by checking with django.utils.timezone.is_naive (or
dt.tzinfo is None) and raise a clear exception (ValueError) if the datetime is
naive so callers must pass an aware datetime; retain the existing behavior of
converting an aware datetime via dt.astimezone(timezone.utc) and then formatting
with "%Y%m%dT%H%M%SZ" to preserve output.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/backend/core/api/openapi.json`:
- Around line 2563-2715: Update the OpenAPI responses by adding the missing 400
and 502 response schemas to the `@extend_schema`(responses=...) annotations in
src/backend/core/api/viewsets/calendar.py for the affected view methods (the
RSVP handler, add event handler, conflicts handler, and list-calendars handler
referenced around lines 65-292); specifically include 400 validation responses
for RSVP, add event, and conflicts, and include 502 for conflicts and list
calendars, then re-run the OpenAPI regeneration (drf-spectacular / your
project’s generate-openapi step) so
/api/v1.0/mailboxes/{mailbox_id}/calendar/... endpoints
(mailboxes_calendar_rsvp_create, mailboxes_calendar_add_create,
mailboxes_calendar_conflicts_create, mailboxes_calendar_calendars_retrieve)
reflect those responses.

In `@src/backend/core/services/calendar/service.py`:
- Around line 291-310: The _put_event function builds event_url by concatenating
an attacker-controlled UID into the path; fix by sanitizing or encoding the UID
before use: after extracting uid in _put_event validate it against a strict
allowlist (e.g. only alphanumerics, dot, hyphen, underscore and a reasonable max
length) and reject or replace any UID with path delimiters, CR/LF, whitespace,
percent sequences, or ".."; alternatively percent-encode the UID as a single
path segment (use urllib.parse.quote with no "/" in the safe set) and then build
event_url using calendar_url.rstrip("/") + "/" + quoted_uid + ".ics"; ensure the
code raises a clear error for rejected UIDs and does not allow raw uid to flow
into event_url (refer to variables uid, calendar_url, event_url and function
_put_event).

---

Nitpick comments:
In `@src/backend/core/services/calendar/service.py`:
- Around line 193-198: The list comprehension calls r.findtext(...) twice per
response; change it to a single-pass by iterating over root.findall(_q(DAV_NS,
"response")) and storing the calendar-data result in a local variable (or use
the walrus operator) once per response (use the same _q(CALDAV_NS,
'calendar-data') key), then append only non-empty values to the result list and
return that list from the function where ET.fromstring(resp.text) and
root.findall are used.
- Around line 32-36: The function _format_utc currently treats tz-naive
datetimes as UTC; change it to require timezone-aware input by checking with
django.utils.timezone.is_naive (or dt.tzinfo is None) and raise a clear
exception (ValueError) if the datetime is naive so callers must pass an aware
datetime; retain the existing behavior of converting an aware datetime via
dt.astimezone(timezone.utc) and then formatting with "%Y%m%dT%H%M%SZ" to
preserve output.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a9df9385-b1a7-427e-9d83-ed8bb879d1db

📥 Commits

Reviewing files that changed from the base of the PR and between f349129 and 4231bee.

⛔ Files ignored due to path filters (13)
  • src/frontend/src/features/api/gen/calendar/calendar.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/index.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/calendar_add_event_request_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/calendar_add_event_response.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/calendar_conflicts_request_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/calendar_conflicts_response.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/calendar_conflicts_response_conflicts_item.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/calendar_list_response.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/calendar_list_response_calendars_item.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/calendar_rsvp_request_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/calendar_rsvp_response.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/index.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/response_enum.ts is excluded by !**/gen/**
📒 Files selected for processing (5)
  • src/backend/core/api/openapi.json
  • src/backend/core/services/calendar/service.py
  • src/backend/core/tests/api/test_calendar.py
  • src/backend/core/tests/api/test_messages_import.py
  • src/backend/core/tests/importer/conftest.py

Comment on lines +2563 to +2715
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CalendarAddEventResponse"
}
}
},
"description": ""
}
}
}
},
"/api/v1.0/mailboxes/{mailbox_id}/calendar/calendars/": {
"get": {
"operationId": "mailboxes_calendar_calendars_retrieve",
"description": "Return the list of calendars available for the mailbox.",
"parameters": [
{
"in": "path",
"name": "mailbox_id",
"schema": {
"type": "string",
"format": "uuid"
},
"required": true
}
],
"tags": [
"calendar"
],
"security": [
{
"cookieAuth": []
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CalendarListResponse"
}
}
},
"description": ""
}
}
}
},
"/api/v1.0/mailboxes/{mailbox_id}/calendar/conflicts/": {
"post": {
"operationId": "mailboxes_calendar_conflicts_create",
"description": "Return a list of events overlapping the requested time range.",
"parameters": [
{
"in": "path",
"name": "mailbox_id",
"schema": {
"type": "string",
"format": "uuid"
},
"required": true
}
],
"tags": [
"calendar"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CalendarConflictsRequestRequest"
}
},
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/CalendarConflictsRequestRequest"
}
}
},
"required": true
},
"security": [
{
"cookieAuth": []
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CalendarConflictsResponse"
}
}
},
"description": ""
}
}
}
},
"/api/v1.0/mailboxes/{mailbox_id}/calendar/rsvp/": {
"post": {
"operationId": "mailboxes_calendar_rsvp_create",
"description": "Submit an RSVP response via a background CalDAV task.",
"parameters": [
{
"in": "path",
"name": "mailbox_id",
"schema": {
"type": "string",
"format": "uuid"
},
"required": true
}
],
"tags": [
"calendar"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CalendarRsvpRequestRequest"
}
},
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/CalendarRsvpRequestRequest"
}
}
},
"required": true
},
"security": [
{
"cookieAuth": []
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CalendarRsvpResponse"
}
}
},
"description": ""
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Document the calendar error responses.

These operations only advertise 200, but the implemented views also return validation 400 responses and 502 for CalDAV failures on list/conflicts. Please update the source @extend_schema(responses=...) annotations and regenerate this file so generated clients model those flows correctly.

Relevant evidence:

  • src/backend/core/api/viewsets/calendar.py:65-129 — RSVP returns 400.
  • src/backend/core/api/viewsets/calendar.py:132-183 — add event returns 400.
  • src/backend/core/api/viewsets/calendar.py:186-252 — conflicts returns 400 and 502.
  • src/backend/core/api/viewsets/calendar.py:255-292 — list calendars returns 502.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/api/openapi.json` around lines 2563 - 2715, Update the
OpenAPI responses by adding the missing 400 and 502 response schemas to the
`@extend_schema`(responses=...) annotations in
src/backend/core/api/viewsets/calendar.py for the affected view methods (the
RSVP handler, add event handler, conflicts handler, and list-calendars handler
referenced around lines 65-292); specifically include 400 validation responses
for RSVP, add event, and conflicts, and include 502 for conflicts and list
calendars, then re-run the OpenAPI regeneration (drf-spectacular / your
project’s generate-openapi step) so
/api/v1.0/mailboxes/{mailbox_id}/calendar/... endpoints
(mailboxes_calendar_rsvp_create, mailboxes_calendar_add_create,
mailboxes_calendar_conflicts_create, mailboxes_calendar_calendars_retrieve)
reflect those responses.

Comment on lines +291 to +310
def _put_event(self, calendar_url, ics_data):
uid = ""
try:
cal = ICalendar.from_ical(ics_data)
for comp in cal.walk("VEVENT"):
uid = str(comp.get("UID") or "")
break
except Exception: # pylint: disable=broad-exception-caught
logger.debug("Could not extract UID from ICS, using random", exc_info=True)
if not uid:
uid = str(uuid.uuid4())

event_url = calendar_url.rstrip("/") + f"/{uid}.ics"
data = ics_data.encode("utf-8") if isinstance(ics_data, str) else ics_data
self._request(
"PUT",
event_url,
data=data,
headers={"Content-Type": "text/calendar; charset=utf-8"},
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, check if the file exists and examine the relevant section
find . -name "service.py" -path "*calendar*" | head -5

Repository: suitenumerique/messages

Length of output: 114


🏁 Script executed:

#!/bin/bash
# Explore the repository structure
git ls-files | grep -E "(calendar|service)" | head -20

Repository: suitenumerique/messages

Length of output: 972


🏁 Script executed:

#!/bin/bash
# Check file size and examine the relevant section
wc -l src/backend/core/services/calendar/service.py

Repository: suitenumerique/messages

Length of output: 116


🏁 Script executed:

#!/bin/bash
# Read lines 291-310 to verify the code matches
sed -n '291,310p' src/backend/core/services/calendar/service.py

Repository: suitenumerique/messages

Length of output: 834


🏁 Script executed:

#!/bin/bash
# Find where _put_event is called
rg "_put_event" src/backend/core/services/calendar/

Repository: suitenumerique/messages

Length of output: 351


🏁 Script executed:

#!/bin/bash
# Search for CalendarAddEventView and CalendarRsvpView mentioned in the review
rg "CalendarAddEventView|CalendarRsvpView" src/backend/

Repository: suitenumerique/messages

Length of output: 656


🏁 Script executed:

#!/bin/bash
# Examine the callers of _put_event in service.py
rg -B 10 -A 5 "_put_event" src/backend/core/services/calendar/service.py | head -60

Repository: suitenumerique/messages

Length of output: 1798


🏁 Script executed:

#!/antml:bash
# Look at CalendarAddEventView and CalendarRsvpView
cat -n src/backend/core/api/viewsets/calendar.py | head -200

Repository: suitenumerique/messages

Length of output: 8335


🏁 Script executed:

#!/bin/bash
# Check the imports at the top of service.py
head -50 src/backend/core/services/calendar/service.py

Repository: suitenumerique/messages

Length of output: 1319


🏁 Script executed:

#!/antml:bash
# Check if quote or re are used in the file already
rg "quote|import re" src/backend/core/services/calendar/service.py

Repository: suitenumerique/messages

Length of output: 82


Path/URL injection vulnerability in CalDAV PUT request — attacker-controlled UID must be encoded or rejected.

The UID is extracted from caller-supplied ICS data (via CalendarAddEventView / CalendarRsvpView) and concatenated directly into event_url without any validation or encoding. A UID containing /, .., ?, #, %2e%2e, CR/LF, or whitespace can escape the validated calendar_url path, redirect to unintended endpoints, or inject newlines into the HTTP request.

🔒 Suggested hardening
+import re
 from urllib.parse import urljoin, urlparse
+from urllib.parse import quote
@@
-        event_url = calendar_url.rstrip("/") + f"/{uid}.ics"
+        # UID comes from caller-supplied ICS; keep it strictly to a safe
+        # character set and percent-encode the rest so it cannot escape the
+        # validated calendar_url path.
+        if not uid or not re.fullmatch(r"[A-Za-z0-9._@+\-:]{1,255}", uid):
+            uid = str(uuid.uuid4())
+        event_url = calendar_url.rstrip("/") + "/" + quote(uid, safe="") + ".ics"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/services/calendar/service.py` around lines 291 - 310, The
_put_event function builds event_url by concatenating an attacker-controlled UID
into the path; fix by sanitizing or encoding the UID before use: after
extracting uid in _put_event validate it against a strict allowlist (e.g. only
alphanumerics, dot, hyphen, underscore and a reasonable max length) and reject
or replace any UID with path delimiters, CR/LF, whitespace, percent sequences,
or ".."; alternatively percent-encode the UID as a single path segment (use
urllib.parse.quote with no "/" in the safe set) and then build event_url using
calendar_url.rstrip("/") + "/" + quoted_uid + ".ics"; ensure the code raises a
clear error for rejected UIDs and does not allow raw uid to flow into event_url
(refer to variables uid, calendar_url, event_url and function _put_event).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant