Skip to content

fix(dav): show canceled/moved occurrences in iMIP emails#60507

Open
ndo84bw wants to merge 1 commit into
nextcloud:masterfrom
ndo84bw:fix/imip-recurring-event-mail-body-master
Open

fix(dav): show canceled/moved occurrences in iMIP emails#60507
ndo84bw wants to merge 1 commit into
nextcloud:masterfrom
ndo84bw:fix/imip-recurring-event-mail-body-master

Conversation

@ndo84bw
Copy link
Copy Markdown

@ndo84bw ndo84bw commented May 18, 2026

Summary

When the organizer modifies a single occurrence of a recurring event, the iMIP notification email did not indicate which occurrence was affected - the body only described the series. This PR makes the body convey the same information that Thunderbird already extracts from the ICS attachment.

Three concrete fixes:

  1. Cancelled: row added for each EXDATE newly present on the master VEVENT that has no matching RECURRENCE-ID override.
  2. Moved: row added for each override whose DTSTART differs from its RECURRENCE-ID (or from its previous DTSTART). Same-day move renders as <date> from <oldtime> to <newtime>; cross-day move as from <olddate> <oldtime> to <newdate> <newtime>.
  3. "updated" wording is now used when the recipient is already on the existing series. EventComparisonService cannot match a brand-new override VEvent to an old VEvent, so the previous code fell through to the "would like to invite you" wording for single-occurrence moves. We now consult the old VCalendar for the same UID to detect that case.

New rows reuse the existing addBodyListItem styling (italic gray label + value on its own line), matching When:, Title:, etc.

Scope decisions (intentionally out of scope)

  • Accept/Decline button suppression on cancellation-only updates (issue's secondary bullet) is not included. An earlier iteration suppressed them, but on inspection the underlying iTip message Sabre generates is still METHOD:REQUEST, so the user's mail client (Thunderbird etc.) keeps showing its own Accept/Decline buttons at the top of the email. Hiding only Nextcloud's body buttons would create inconsistent UI without solving the underlying problem. A proper fix would require Sabre's TipBroker to emit a METHOD:CANCEL with RECURRENCE-ID for single-occurrence deletions, which is a much larger change with cross-client compatibility risk and belongs in its own PR.
  • Dedicated Renamed: row for single-occurrence title changes (issue's Case D) is not included. The current behavior already renders the renamed occurrence's title + when in the body and now uses "updated" wording, which the issue notes is "actually useful". The spec under "Expected behavior" only enumerates Canceled and Moved. Happy to add it as a follow-up if maintainers prefer.

Implementation notes

  • IMipService::buildBodyData() gained two optional ?VCalendar parameters (new and old). The original two-argument signature is preserved (existing tests untouched).
  • New private helpers: detectRecurrenceChanges(), collectExdates(), findOverrideEvents(), formatCanceledOccurrence(), formatMovedOccurrence(), toMutableDateTime(). One public helper findMasterEvent() (needed by IMipPlugin to detect existing series).
  • Notable footgun discovered: Sabre\VObject\Property\ICalendar\DateTime::getDateTimes() returns \DateTimeImmutable, which is a sibling of \DateTime, not a subclass. OC\L10N\L10N::l() at lib/private/L10N/L10N.php:156 checks if ($data instanceof \DateTime) and falls through to (int)$data for anything else - silently casting an Immutable to 1 and rendering as 1970-01-01 00:00:01. A small toMutableDateTime() helper converts before formatting. Worth considering as a separate hardening of IL10N::l() so it accepts \DateTimeInterface, since any future caller wiring Sabre's parsed dates into l() will hit the same trap.

Translations

German translations for the new strings (Cancelled:, Moved:, %1$s at %2$s, %1$s from %2$s to %3$s, from %1$s to %2$s, from %1$s %2$s to %3$s %4$s) are included manually in apps/dav/l10n/de.{js,json} and apps/dav/l10n/de_DE.{js,json} so DE users see them on day one. I'm aware Transifex is the source of truth and the next sync will overwrite these - by then Transifex will have its own translation for the same strings, so the effect is the same. Happy to drop the manual entries if maintainers prefer the strict Transifex-only workflow.

The new format strings (%1$s at %2$s, from %1$s to %2$s, etc.) are intentionally generic so they can be translated naturally per locale; TRANSLATORS comments are inline with each l10n->t() call to give context.

Test plan

Setup (applies to all four tests):

  • User A - organizer aka "admin"; creates a daily recurring event in the Nextcloud Calendar web UI, invites B and C via the user picker (principal-based scheduling).
  • User B - invitee, NC locale en, has not accepted the series yet.
  • User C - invitee, NC locale de, has accepted the series (with Thunderbird).
  • Both attendees read the resulting iMIP emails in Thunderbird with CalDAV sync paused, to observe raw email content unfiltered by any client-side merging.
  • Each test run twice on the same Nextcloud 33.0.3 instance: once with the upstream stable33 code (Before), once with this PR applied (After).

Test 1 - delete a single occurrence (issue Case A & C combined)

Action: User A deletes occurrence on <DATE> via "Delete this occurrence" in the Nextcloud Calendar web UI.

User B (EN, not accepted)

Subject (unchanged)

Invitation updated: Project NC Weekly

Body

  admin updated the event "Project NC Weekly"

  Title:
  Project NC Weekly

  When:
  Every Week on Monday between 10:00 AM - 10:30 AM (Europe/Berlin)

  Occurring:
  In 6 days on May 25, 2026 then on June 1, 2026 and June 8, 2026
+
+ Cancelled:
+ Monday, July 13, 2026 at 10:00 AM

User C (DE, accepted)

Subject (unchanged)

Einladung aktualisiert: Project NC Weekly

Body

  admin hat die Veranstaltung "Project NC Weekly" aktualisiert

  Titel:
  Project NC Weekly

  Wann:
  Jede Woche am Montag zwischen 10:00 - 10:30 (Europe/Berlin)

  Findet statt:
  In 6 Tagen am 25. Mai 2026 danach am 1. Juni 2026 und 8. Juni 2026
+
+ Abgesagt:
+ Montag, 13. Juli 2026 um 10:00

Expected change After this PR: new Cancelled: <date> at <time> (B) / Abgesagt: <datum> um <zeit> (C) row appears in the body. Subject and heading already used "updated" wording before the PR for the delete case, so unchanged.


Test 2 - move a single occurrence, same day (issue Case B)

Action: User A moves the occurrence on <DATE> from <oldtime> to <newtime> (same day, time-only change) in the Nextcloud Calendar web UI.

User B (EN, not accepted)

Subject

- Invitation: Project NC Weekly
+ Invitation updated: Project NC Weekly

Body

- admin would like to invite you to "Project NC Weekly"
+ admin updated the event "Project NC Weekly"

  Title:
  Project NC Weekly

  When:
  In 2 months on Monday, July 20, 2026 between 2:00 PM - 2:30 PM (Europe/Berlin)
+
+ Moved:
+ Monday, July 20, 2026 from 10:00 AM to 2:00 PM

User C (DE, accepted)

Subject

- Einladung: Project NC Weekly
+ Einladung aktualisiert: Project NC Weekly

Body

- admin möchte dich zu "Project NC Weekly" einladen.
+ admin hat die Veranstaltung "Project NC Weekly" aktualisiert

  Titel:
  Project NC Weekly

  Wann:
  In 2 Monaten am Montag, 20. Juli 2026 zwischen 14:00 - 14:30 (Europe/Berlin)
+
+ Verschoben:
+ Montag, 20. Juli 2026 von 10:00 auf 14:00

Expected change After this PR:

  1. New Moved: <date> from <oldtime> to <newtime> (B) / Verschoben: <datum> von <alt> auf <neu> (C) row appears in the body.
  2. For User B specifically: subject and heading switch from "Invitation:" / "would like to invite you" to "Invitation updated:" / "updated the event" (Bug from issue's Case B).

Test 3 - move a single occurrence, different day and time (additional, not in original issue)

Action: User A moves the occurrence on <DATE> to a different day and a different time in the Nextcloud Calendar web UI.

User B (EN, not accepted)

Subject

- Invitation: Project NC Weekly
+ Invitation updated: Project NC Weekly

Body

- admin would like to invite you to "Project NC Weekly"
+ admin updated the event "Project NC Weekly"

  Title:
  Project NC Weekly

  When:
  In 2 months on Tuesday, July 28, 2026 between 9:00 AM - 9:30 AM (Europe/Berlin)
+
+ Moved:
+ from Monday, July 27, 2026 10:00 AM to Tuesday, July 28, 2026 9:00 AM

User C (DE, accepted)

Subject

- Einladung: Project NC Weekly
+ Einladung aktualisiert: Project NC Weekly

Body

- admin möchte dich zu "Project NC Weekly" einladen.
+ admin hat die Veranstaltung "Project NC Weekly" aktualisiert

  Titel:
  Project NC Weekly

  Wann:
  In 2 Monaten am Dienstag, 28. Juli 2026 zwischen 09:00 - 09:30 (Europe/Berlin)
+
+ Verschoben:
+ von Montag, 27. Juli 2026 10:00 auf Dienstag, 28. Juli 2026 09:00

Expected change After this PR: new Moved: from <olddate> <oldtime> to <newdate> <newtime> (B) / Verschoben: von <altdatum> <altzeit> auf <neudatum> <neuzeit> (C) row.


Test 4 - rename a single occurrence (issue Case D)

Action: User A changes the title of the occurrence on <DATE> to a new title in the Nextcloud Calendar web UI.

User B (EN, not accepted)

Subject

- Invitation: Project NC Weekly / Next Chapter
+ Invitation updated: Project NC Weekly / Next Chapter

Body

- admin would like to invite you to "Project NC Weekly / Next Chapter"
+ admin updated the event "Project NC Weekly / Next Chapter"

  Title:
  Project NC Weekly / Next Chapter

  When:
  In 2 months on Monday, August 3, 2026 between 10:00 AM - 10:30 AM (Europe/Berlin)

User C (DE, accepted)

Subject

- Einladung: Project NC Weekly / Next Chapter
+ Einladung aktualisiert: Project NC Weekly / Next Chapter

Body

- admin möchte dich zu "Project NC Weekly / Next Chapter" einladen.
+ admin hat die Veranstaltung "Project NC Weekly / Next Chapter" aktualisiert

  Titel:
  Project NC Weekly / Next Chapter

  Wann:
  In 2 Monaten am Montag, 3. August 2026 zwischen 10:00 - 10:30 (Europe/Berlin)

Expected change After this PR: heading switches from "would like to invite you" to "updated the event" for User B. No dedicated Renamed: row - see Scope decisions.

Note: The original issue (#60451) only described User-B-style behavior for the rename case and implied this might be specific to non-accepted attendees. Re-testing for this PR showed both User B and User C (accepted) get the buggy "would like to invite" heading before the patch - git log confirms no relevant code change in the wording logic between v33.0.2 (issue version) and current stable33, so the original report likely had a small testing-setup variation. The fix in this PR covers both cases.


Rendered styling (screenshots)

Text-only paste does not convey that the new Cancelled: / Moved: labels are styled identically to the existing When: / Title: rows (italic, gray #777, on a line of their own above the value). The screenshots below show the rendered HTML email.

Cancelled row (after Test 1):

Screenshot_20260518_144939

Moved row (after Test 2):

Screenshot_20260518_144915

Unit tests

Added six cases in apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php:

Test Verifies
testBuildBodyDataDetectsCanceledOccurrence Newly-added EXDATE → meeting_canceled_occurrences populated with correct date
testBuildBodyDataIgnoresPreexistingExdates Pre-existing EXDATE not reported as newly canceled
testBuildBodyDataDetectsMovedOccurrence New override with shifted DTSTART → meeting_moved_occurrences populated (same-day)
testBuildBodyDataDetectsMovedOccurrenceAcrossDays Same as above, cross-day format
testBuildBodyDataReturnsNoExtraKeysWhenNothingRecurrenceChanged Identical old/new VCalendars → no canceled/moved keys
testFindMasterEventSkipsOverrides Master finder ignores VEVENTs that carry a RECURRENCE-ID

The l10n stub in these tests intentionally type-hints \DateTime (not \DateTimeInterface) so any regression of the DateTimeImmutable issue would surface as a TypeError rather than silently producing epoch-0 output.

Locally run against this branch (apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php):

PHPUnit 11.5.50 by Sebastian Bergmann and contributors.

................................                                  32 / 32 (100%)

Tests: 32, Assertions: 248

(The runner additionally emits PHPUnit Deprecations: 1"Your XML configuration validates against a deprecated schema" referring to apps/dav/tests/unit/phpunit.xml. Pre-existing on master, unrelated to this PR.)

php-cs-fixer --dry-run reports Found 0 of 3 files that can be fixed on the three modified PHP files.

Checklist

  • Code is properly formatted
  • Sign-off message is added to all commits
  • Tests (unit, integration, api and/or acceptance) are included - 6 new unit cases in IMipServiceTest
  • Screenshots before/after for front-end changes - text bodies pasted above for each case; rendered styling included as screenshots
  • Documentation (manuals or wiki) has been updated or is not required - not required
  • Backports requested where applicable - please backport to stable33 (issue affects 33.x, verified there)
  • Labels added where applicable
  • Milestone added for target branch/version

AI (if applicable)

  • The content of this PR was partly or fully generated using AI (paired with Claude Code; human-reviewed and manually tested on a Nextcloud 33.0.3 instance with three attendees in two languages)

When the organizer modifies a single occurrence of a recurring event,
the iMIP notification email body did not indicate which occurrence
was affected (issue nextcloud#60451).

This adds two new rows to the invitation email when an iTip REQUEST
diffs from the previous calendar state:

- "Cancelled: <date>" for each EXDATE newly added to the master VEvent
  that has no corresponding RECURRENCE-ID override.
- "Moved: <date> from <oldtime> to <newtime>" (same-day) or
  "Moved: from <olddate> <oldtime> to <newdate> <newtime>" (cross-day)
  for each override whose DTSTART differs from its RECURRENCE-ID (or
  from its previous DTSTART).

The new rows reuse the existing addBodyListItem styling (italic gray
label + value), matching the look of "When:", "Title:", etc.

Also fixes the heading wording for single-occurrence updates: when a
move adds a brand-new override VEvent, EventComparisonService cannot
match it to an old VEvent and IMipPlugin previously fell through to
the "would like to invite you" wording. The recipient is already on
the existing series, so we now check the old VCalendar for the same
UID and use the "admin updated the event" wording in that case.

German translations are included for the new label and date/time
format strings (de.js/de.json/de_DE.js/de_DE.json) so DE users see
"Abgesagt:" / "Verschoben:" and natural prepositions ("um", "von …
auf …") immediately; other languages will be picked up via Transifex.

Implementation notes:
- buildBodyData() now accepts optional ?VCalendar parameters so it can
  diff EXDATE values and sibling RECURRENCE-ID overrides against the
  previous calendar state.
- Sabre's Property\ICalendar\DateTime::getDateTimes() returns
  \DateTimeImmutable, which is a sibling of \DateTime, not a subclass.
  Nextcloud's L10N::l() only matches \DateTime via instanceof and casts
  other input via (int), producing timestamp 1 and rendering as
  "1970-01-01 00:00:01" for any Immutable. A toMutableDateTime() helper
  converts before formatting.

Signed-off-by: Nico Donath <ndo84bw@gmx.de>
@ndo84bw ndo84bw requested review from Altahrim, nfebe, provokateurin, sorbaugh and susnux and removed request for a team May 18, 2026 13:27
@SebastianKrupinski
Copy link
Copy Markdown
Contributor

Hi @ndo84bw

Thank you for the PR, we will review these changes as soon as possible.

In the mean time I noticed that your PR includes manually edited translation files. We use a external service for translations, and they should not be included in the PR.

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.

email body for recurring event modifications does not show which occurrence was affected

2 participants