Skip to content

Apply inbound iTIP METHOD:REPLY to the organizer's calendar#753

Open
mosa-riel wants to merge 1 commit into
suitenumerique:mainfrom
mosacloud:feat/itip-reply-apply
Open

Apply inbound iTIP METHOD:REPLY to the organizer's calendar#753
mosa-riel wants to merge 1 commit into
suitenumerique:mainfrom
mosacloud:feat/itip-reply-apply

Conversation

@mosa-riel

@mosa-riel mosa-riel commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Closes the calendar RSVP loop: applies an inbound iTIP METHOD:REPLY to the
organizer's stored event — the mirror of the attendee-side respond_to_event.
Off by default behind CALENDAR_ITIP_REPLY_ENABLED.

How: detect a text/calendar; method=REPLY part in the inbound pipeline →
gate → async calendar_apply_reply_taskCalDAVService.apply_reply finds the
event by UID and writes the replying attendee's PARTSTAT back to its real
resource href with If-Match.

Trust: consumes the pipeline's existing postmark["auth"] verdict — no new
auth mechanism. The DMARC-verified From is the only ATTENDEE that can be moved,
and must itself be RSVPing, so a crafted multi-ATTENDEE reply can't move a third
party. fail never applied; none only with CALENDAR_ITIP_REPLY_APPLY_UNVERIFIED;
an absent verdict counts as verified only when a supported inbound-auth mode ran.

CalDAV correctness: write to the event's real href (filenames needn't equal
the UID) with If-Match + 412 refetch-retry; SCHEDULE-AGENT=CLIENT to suppress
server re-fanout; SEQUENCE + clamped per-attendee DTSTAMP staleness guard;
recurrence-instance and DTSTART-less replies rejected; oversized parts capped.

Tests: unit trust matrix + the two-ATTENDEE attack; apply_reply against live
Radicale (non-UID filename, If-Match, 412 retry); full-stack e2e through the MTA
deliver endpoint.

Follow-up: #752 (respond_to_event shares the same href hazard on the
interactive RSVP path). Open question: apply in-backend as here, or hand the
reply to SabreDAV's scheduling inbox?

Summary by CodeRabbit

  • New Features
    • Added inbound iTIP METHOD:REPLY handling to update matching CalDAV attendee PARTSTATs.
    • Added trust-gated application (including recurrence-instance rejection) and fan-out task enqueueing when applicable.
    • Introduced iTIP REPLY feature flags and exposed iTIP reply verification status in message metadata.
  • Bug Fixes
    • Treats unsupported/unknown inbound auth modes as disabled.
    • Preserves X-STMSG-REPLY-DTSTAMP during calendar rebuild/sanitization.
  • Tests
    • Added unit and end-to-end coverage for parsing, gating, CalDAV apply behavior, concurrency handling, and message flagging.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: cec009d8-8202-4c2a-816a-83f489d446ed

📥 Commits

Reviewing files that changed from the base of the PR and between a787e66 and 3be281a.

📒 Files selected for processing (11)
  • src/backend/core/mda/inbound_auth.py
  • src/backend/core/mda/inbound_tasks.py
  • src/backend/core/mda/itip.py
  • src/backend/core/models.py
  • src/backend/core/services/calendar/ics_rebuild.py
  • src/backend/core/services/calendar/service.py
  • src/backend/core/services/calendar/tasks.py
  • src/backend/core/tests/api/test_calendar.py
  • src/backend/core/tests/mda/test_inbound_auth.py
  • src/backend/core/tests/test_itip.py
  • src/backend/messages/settings.py

📝 Walkthrough

Walkthrough

Adds inbound iTIP METHOD:REPLY detection and authentication gating, CalDAV attendee status updates with concurrency protection, Celery application tasks, inbound-message scheduling, feature flags, message header propagation, and unit, service, and end-to-end tests.

Changes

Inbound iTIP Reply Processing

Layer / File(s) Summary
iTIP REPLY detection and trust evaluation
src/backend/core/mda/itip.py, src/backend/core/tests/test_itip.py
Calendar parts are discovered and parsed, sender-attendee matching and recurrence checks are applied, and auth verdicts determine whether replies are accepted.
CalDAV REPLY application and concurrency handling
src/backend/core/services/calendar/service.py, src/backend/core/services/calendar/ics_rebuild.py, src/backend/core/services/calendar/tasks.py, src/backend/core/tests/api/test_calendar.py
Organizer events are located and conditionally updated with attendee PARTSTAT, SCHEDULE-AGENT, and reply timestamp markers, including stale-reply and retry handling.
Inbound pipeline scheduling and result propagation
src/backend/core/mda/inbound_auth.py, src/backend/core/mda/inbound_tasks.py, src/backend/core/models.py, src/backend/messages/settings.py, src/backend/core/tests/mda/test_inbound_auth.py, src/backend/core/tests/api/test_calendar.py
Feature flags, supported inbound-auth checks, CalDAV task scheduling, mailbox fan-out, iTIP postmarks, and inbound end-to-end coverage are added.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant InboundTask
  participant ItipEvaluator
  participant CalendarTask
  participant CalDAVService
  InboundTask->>ItipEvaluator: evaluate_inbound_reply(parsed_email, auth_verdict, apply_unverified)
  ItipEvaluator-->>InboundTask: accepted ICS and attendee
  InboundTask->>CalendarTask: enqueue calendar_apply_reply_task
  CalendarTask->>CalDAVService: apply_reply(ics_data, attendee_email, organizer_email)
Loading

Suggested reviewers: sylvinus

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.97% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: applying inbound iTIP METHOD:REPLY messages to the organizer's calendar.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/core/mda/inbound_tasks.py`:
- Around line 231-234: The inbound-task fan-out currently allows one
authenticated sender to generate unbounded REPORTs across mailbox users and
writable calendars. Before enabling this dispatch, add abuse protection in the
inbound task flow around the TODO: enforce a per-sender-domain/mailbox rate
limit, or otherwise cap and coalesce REPORT dispatches, while preserving normal
delivery behavior within the configured limit.

In `@src/backend/core/services/calendar/service.py`:
- Around line 545-562: The _put_resource method must never issue an
unconditional PUT when etag is missing or empty. Validate the ETag before
sending the request, and either obtain a fresh non-empty ETag or return/raise
the established concurrency failure; only include If-Match and perform the write
when a valid ETag is available.
- Around line 848-853: Update _reply_query_window to validate that the reply’s
normalized start and end are ordered, clamp both values to the supported
calendar date bounds before applying the one-day expansion, and cap the
resulting query window to the permitted maximum span. Ensure invalid or extreme
attacker-controlled DTSTART/DTEND values cannot underflow arithmetic or produce
oversized REPORT queries.
- Around line 745-755: Update the reply parsing logic around the DTSTAMP
extraction to require a valid DTSTAMP instead of returning None when it is
absent. Ensure records without DTSTAMP are rejected or otherwise excluded before
_record_reply_dtstamp processes them, while preserving the existing sequence and
timestamp handling for valid replies.

In `@src/backend/core/services/calendar/tasks.py`:
- Around line 185-187: Remove PII from all three iTIP error paths: in
src/backend/core/services/calendar/tasks.py:185-187, update the exception
handling around the inbound iTIP REPLY to emit one redacted Sentry event without
task-frame locals or raw ICS, relying on logger.exception rather than calling
capture_exception separately; in src/backend/core/mda/itip.py:150-152, remove
from_addr from the INFO log; and in
src/backend/core/mda/inbound_tasks.py:260-264, replace the mailbox address with
an internal identifier or generic message.
- Around line 178-184: The CalDAVError handler in the task must retry transient
failures instead of returning FAILURE. Classify wrapped timeout/connection
errors and HTTP 429/5xx responses from CalDAVService as retryable, call
self.retry(...) for those cases, and preserve the existing failure result for
permanent 4xx and configuration errors.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5d9ab507-79f3-4adc-8cdc-8d110c582bff

📥 Commits

Reviewing files that changed from the base of the PR and between 39f2d9c and f30566b.

📒 Files selected for processing (11)
  • src/backend/core/mda/inbound_auth.py
  • src/backend/core/mda/inbound_tasks.py
  • src/backend/core/mda/itip.py
  • src/backend/core/models.py
  • src/backend/core/services/calendar/ics_rebuild.py
  • src/backend/core/services/calendar/service.py
  • src/backend/core/services/calendar/tasks.py
  • src/backend/core/tests/api/test_calendar.py
  • src/backend/core/tests/mda/test_inbound_auth.py
  • src/backend/core/tests/test_itip.py
  • src/backend/messages/settings.py

Comment thread src/backend/core/mda/inbound_tasks.py
Comment thread src/backend/core/services/calendar/service.py
Comment thread src/backend/core/services/calendar/service.py Outdated
Comment thread src/backend/core/services/calendar/service.py
Comment on lines +178 to +184
except CalDAVError as e:
logger.warning("Apply-reply failed: %s", e)
return {
"status": "FAILURE",
"result": None,
"error": str(e),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep outline src/backend/core/services/calendar/tasks.py \
  --match 'calendar_apply_reply_task|CalDAVError' --view expanded

rg -nP --type=py -C4 \
  '\bself\.retry\s*\(|autoretry_for|retry_backoff' \
  src/backend/core

Repository: suitenumerique/messages

Length of output: 6077


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '130,210p' src/backend/core/services/calendar/tasks.py | cat -n

echo
echo '--- search for retry / Sentry patterns in this file ---'
rg -n -C3 'self\.retry|autoretry_for|capture_exception|logger\.exception|logger\.warning|CalDAVError|Exception' src/backend/core/services/calendar/tasks.py

Repository: suitenumerique/messages

Length of output: 8266


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
path = Path("src/backend/core/services/calendar/tasks.py")
text = path.read_text()
for i, line in enumerate(text.splitlines(), 1):
    if 130 <= i <= 210:
        print(f"{i:4d}: {line}")
PY

Repository: suitenumerique/messages

Length of output: 3346


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/backend/core/services/calendar/service.py --view expanded

echo
echo '--- CalDAVError definition and raise sites ---'
rg -n -C4 'class CalDAVError|raise CalDAVError|except .*CalDAVError|timeout|429|5\d\d|requests|ConnectionError|HTTPError' \
  src/backend/core/services/calendar/service.py src/backend/core/services/calendar -g '*.py'

Repository: suitenumerique/messages

Length of output: 42657


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
path = Path("src/backend/core/services/calendar/service.py")
text = path.read_text().splitlines()
for i, line in enumerate(text, 1):
    if 1 <= i <= 260:
        print(f"{i:4d}: {line}")
PY

Repository: suitenumerique/messages

Length of output: 12193


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- call sites for calendar_apply_reply_task ---'
rg -n -C3 'calendar_apply_reply_task|apply_reply_task' src/backend

echo
echo '--- task-result status handling in calendar area ---'
rg -n -C3 '"status": "FAILURE"|status == "FAILURE"|status.*FAILURE|result.*FAILURE' src/backend/core/services/calendar src/backend -g '*.py'

Repository: suitenumerique/messages

Length of output: 31145


Retry retryable CalDAV failures here. CalDAVError also wraps timeouts/connection errors and HTTP 429/5xx from CalDAVService; returning "FAILURE" still lets this Celery task complete, so those transient cases will never be retried. Split retryable network/server errors from permanent 4xx/configuration failures and self.retry(...) the former.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/services/calendar/tasks.py` around lines 178 - 184, The
CalDAVError handler in the task must retry transient failures instead of
returning FAILURE. Classify wrapped timeout/connection errors and HTTP 429/5xx
responses from CalDAVService as retryable, call self.retry(...) for those cases,
and preserve the existing failure result for permanent 4xx and configuration
errors.

Comment on lines +185 to +187
except Exception as e: # pylint: disable=broad-exception-caught
capture_exception(e)
logger.exception("Error applying inbound iTIP REPLY")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Remove PII from iTIP logs and exception telemetry. The new path exposes attendee/mailbox addresses and potentially the complete ICS payload.

  • src/backend/core/services/calendar/tasks.py#L185-L187: use one redacted Sentry event without task-frame locals or raw ICS.
  • src/backend/core/mda/itip.py#L150-L152: remove from_addr from the INFO log.
  • src/backend/core/mda/inbound_tasks.py#L260-L264: replace the mailbox address with an internal identifier or generic message.

As per coding guidelines, do not log PII. Based on learnings, logger.exception(...) already reports through Sentry LoggingIntegration.

📍 Affects 3 files
  • src/backend/core/services/calendar/tasks.py#L185-L187 (this comment)
  • src/backend/core/mda/itip.py#L150-L152
  • src/backend/core/mda/inbound_tasks.py#L260-L264
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/services/calendar/tasks.py` around lines 185 - 187, Remove
PII from all three iTIP error paths: in
src/backend/core/services/calendar/tasks.py:185-187, update the exception
handling around the inbound iTIP REPLY to emit one redacted Sentry event without
task-frame locals or raw ICS, relying on logger.exception rather than calling
capture_exception separately; in src/backend/core/mda/itip.py:150-152, remove
from_addr from the INFO log; and in
src/backend/core/mda/inbound_tasks.py:260-264, replace the mailbox address with
an internal identifier or generic message.

Sources: Coding guidelines, Learnings

@mosa-riel
mosa-riel force-pushed the feat/itip-reply-apply branch 2 times, most recently from a787e66 to 6d39074 Compare July 18, 2026 23:45

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/core/mda/inbound_tasks.py`:
- Around line 243-253: Update the channel lookup and dispatch flow in the
inbound task to handle every CalDAV channel matching the mailbox instead of
selecting only the first result. Iterate through all matching channels, enqueue
calendar_apply_reply_task for each channel, and return only after the full set
has been dispatched; preserve the existing payload for each task.

In `@src/backend/core/services/calendar/service.py`:
- Around line 796-802: Update the reply-processing flow around
_apply_reply_to_resource to carry the recipient mailbox identity through the
task, and require the stored event’s ORGANIZER to match it before accepting a
UID match. Reject non-matching copies and continue searching for the canonical
organizer event, then add a regression test covering duplicate
organizer/attendee copies.
- Around line 548-552: Update the response parsing loop around the DAV href
handling to resolve relative href values against the queried calendar URL before
appending them to out. Preserve absolute hrefs unchanged, and ensure subsequent
GET/PUT operations use the resolved resource URL.

In `@src/backend/core/services/calendar/tasks.py`:
- Around line 129-136: Update calendar_apply_reply_task so Celery does not
persist its raw task arguments, especially ics_data and attendee_email, in the
result backend: configure this task to ignore its result using the project’s
established Celery task pattern, or change the enqueueing flow to pass an
internal record ID and load the ICS data inside the task. Preserve the task’s
existing reply-processing behavior.

In `@src/backend/core/tests/api/test_calendar.py`:
- Around line 2571-2574: Update both attendee lookup helpers, including the
shown loop and the corresponding helper near the second location, to normalize
each address by stripping an optional mailto: prefix and lowercasing before
comparing exact equality with the normalized target. Replace the endswith-based
match while preserving the existing PARTSTAT return behavior.
- Around line 2895-2896: Update the clamp assertion in the relevant calendar
test to compare recorded against the current UTC time rather than the fixed
January 1, 2030 cutoff, while preserving the non-null assertion and verifying
the clamped timestamp remains before now.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2042f9b9-dce2-4d32-9c63-99711270885e

📥 Commits

Reviewing files that changed from the base of the PR and between f30566b and a787e66.

📒 Files selected for processing (11)
  • src/backend/core/mda/inbound_auth.py
  • src/backend/core/mda/inbound_tasks.py
  • src/backend/core/mda/itip.py
  • src/backend/core/models.py
  • src/backend/core/services/calendar/ics_rebuild.py
  • src/backend/core/services/calendar/service.py
  • src/backend/core/services/calendar/tasks.py
  • src/backend/core/tests/api/test_calendar.py
  • src/backend/core/tests/mda/test_inbound_auth.py
  • src/backend/core/tests/test_itip.py
  • src/backend/messages/settings.py

Comment thread src/backend/core/mda/inbound_tasks.py Outdated
Comment thread src/backend/core/services/calendar/service.py Outdated
Comment thread src/backend/core/services/calendar/service.py
Comment thread src/backend/core/services/calendar/tasks.py Outdated
Comment thread src/backend/core/tests/api/test_calendar.py Outdated
Comment thread src/backend/core/tests/api/test_calendar.py Outdated
@mosa-riel
mosa-riel force-pushed the feat/itip-reply-apply branch 2 times, most recently from 3456346 to 162095e Compare July 19, 2026 00:09
Close the RSVP loop: when an attendee's iTIP REPLY email lands in the
organizer's mailbox, record their PARTSTAT on the organizer's stored copy
of the event. Mirror of the attendee-side respond_to_event.

- core/mda/itip.py: detect a text/calendar METHOD:REPLY part and gate it on
  the inbound pipeline's postmark["auth"] verdict. The DMARC-verified From is
  threaded through enqueue -> task -> apply_reply as the only ATTENDEE that may
  be modified, and must itself be an ATTENDEE carrying a PARTSTAT — so a crafted
  multi-ATTENDEE reply (aligned but PARTSTAT-less From + a PARTSTAT on a third
  party) cannot move anyone else. Oversized parts (>512 KB) and recurrence-
  instance replies (RECURRENCE-ID) are rejected at the gate.
- Verdict policy: "fail" never applied; "none" skipped unless
  CALENDAR_ITIP_REPLY_APPLY_UNVERIFIED; verified applied and flagged. An absent
  verdict counts as verified ONLY when inbound auth ran with a SUPPORTED mode
  (inbound_auth_enabled) — DKIM/DMARC disabled or a typo'd mode downgrades to
  unverifiable, so an auth-less deployment can't be spoofed. Provenance in
  postmark["itip-reply"].
- CalDAVService.apply_reply: locate the event via a time-range REPORT that also
  returns d:getetag + d:href (resolved against the calendar URL), then write
  back to the event's OWN resource (filenames need not equal the UID) with
  If-Match; if the REPORT gave no etag one is fetched and the write refused
  rather than sent unconditionally. On 412 re-fetch, re-check staleness, re-apply
  and retry once, else concurrent-modification. Only a copy whose ORGANIZER
  matches the recipient mailbox is written — a same-UID copy on which the mailbox
  is merely an attendee is skipped. Apply only the verified attendee's PARTSTAT;
  SCHEDULE-AGENT=CLIENT to suppress SabreDAV re-fanout; SEQUENCE + clamped
  per-attendee DTSTAMP staleness guard (all VEVENTs). REPLYs without DTSTART or
  DTSTAMP rejected; the attacker-controlled time-range window is normalized,
  span-capped and bounds-clamped. Distinct no-op reasons.
- calendar_apply_reply_task (ignore_result — fire-and-forget) + inbound hook
  fanning out across all of the mailbox's CalDAV channels (or its users, capped),
  only when a CalDAV backend is configured. Per-(sender,mailbox) rate limiting is
  a TODO.
- CALENDAR_ITIP_REPLY_ENABLED flag (off by default).

respond_to_event (interactive RSVP) has a pre-existing, independent variant of
the same href hazard (UID-derived URL) — tracked upstream as a follow-up.

Tests: unit trust matrix + two-ATTENDEE attack + recurrence + oversized +
auth-mode validation + DTSTAMP clamp + window bounds; apply_reply against live
Radicale incl. non-UID filename, If-Match, 412 retry, no-etag refusal, missing
DTSTART/DTSTAMP, non-organizer-copy skip, all-channels fan-out; full-stack e2e.
@mosa-riel
mosa-riel force-pushed the feat/itip-reply-apply branch from 162095e to 3be281a Compare July 19, 2026 17:39
@sylvinus sylvinus self-assigned this Jul 19, 2026
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.

2 participants