Skip to content

Commit b266efe

Browse files
hamza221backportbot[bot]
authored andcommitted
fix(caldav-delegation): send notification to delegator
Signed-off-by: Hamza <hamzamahjoubi221@gmail.com>
1 parent ac7f429 commit b266efe

6 files changed

Lines changed: 326 additions & 4 deletions

File tree

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@
330330
'OCA\\DAV\\Listener\\AddressbookListener' => $baseDir . '/../lib/Listener/AddressbookListener.php',
331331
'OCA\\DAV\\Listener\\BirthdayListener' => $baseDir . '/../lib/Listener/BirthdayListener.php',
332332
'OCA\\DAV\\Listener\\CalendarContactInteractionListener' => $baseDir . '/../lib/Listener/CalendarContactInteractionListener.php',
333+
'OCA\\DAV\\Listener\\CalendarDelegateActionListener' => $baseDir . '/../lib/Listener/CalendarDelegateActionListener.php',
333334
'OCA\\DAV\\Listener\\CalendarDeletionDefaultUpdaterListener' => $baseDir . '/../lib/Listener/CalendarDeletionDefaultUpdaterListener.php',
334335
'OCA\\DAV\\Listener\\CalendarFederationNotificationListener' => $baseDir . '/../lib/Listener/CalendarFederationNotificationListener.php',
335336
'OCA\\DAV\\Listener\\CalendarObjectReminderUpdaterListener' => $baseDir . '/../lib/Listener/CalendarObjectReminderUpdaterListener.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ class ComposerStaticInitDAV
345345
'OCA\\DAV\\Listener\\AddressbookListener' => __DIR__ . '/..' . '/../lib/Listener/AddressbookListener.php',
346346
'OCA\\DAV\\Listener\\BirthdayListener' => __DIR__ . '/..' . '/../lib/Listener/BirthdayListener.php',
347347
'OCA\\DAV\\Listener\\CalendarContactInteractionListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarContactInteractionListener.php',
348+
'OCA\\DAV\\Listener\\CalendarDelegateActionListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarDelegateActionListener.php',
348349
'OCA\\DAV\\Listener\\CalendarDeletionDefaultUpdaterListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarDeletionDefaultUpdaterListener.php',
349350
'OCA\\DAV\\Listener\\CalendarFederationNotificationListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarFederationNotificationListener.php',
350351
'OCA\\DAV\\Listener\\CalendarObjectReminderUpdaterListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarObjectReminderUpdaterListener.php',

apps/dav/lib/AppInfo/Application.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
use OCA\DAV\Listener\AddressbookListener;
4848
use OCA\DAV\Listener\BirthdayListener;
4949
use OCA\DAV\Listener\CalendarContactInteractionListener;
50+
use OCA\DAV\Listener\CalendarDelegateActionListener;
5051
use OCA\DAV\Listener\CalendarDeletionDefaultUpdaterListener;
5152
use OCA\DAV\Listener\CalendarFederationNotificationListener;
5253
use OCA\DAV\Listener\CalendarObjectReminderUpdaterListener;
@@ -214,6 +215,12 @@ public function register(IRegistrationContext $context): void {
214215
$context->registerEventListener(CalendarObjectUpdatedEvent::class, CalendarFederationNotificationListener::class);
215216
$context->registerEventListener(CalendarObjectDeletedEvent::class, CalendarFederationNotificationListener::class);
216217

218+
$context->registerEventListener(CalendarObjectCreatedEvent::class, CalendarDelegateActionListener::class);
219+
$context->registerEventListener(CalendarObjectUpdatedEvent::class, CalendarDelegateActionListener::class);
220+
$context->registerEventListener(CalendarObjectDeletedEvent::class, CalendarDelegateActionListener::class);
221+
$context->registerEventListener(CalendarObjectMovedToTrashEvent::class, CalendarDelegateActionListener::class);
222+
$context->registerEventListener(CalendarObjectRestoredEvent::class, CalendarDelegateActionListener::class);
223+
217224
$context->registerNotifierService(NotifierCalDAV::class);
218225
$context->registerNotifierService(NotifierCardDAV::class);
219226

apps/dav/lib/CalDAV/CalDavBackend.php

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1451,10 +1451,15 @@ public function updateCalendarObject($calendarId, $objectUri, $calendarData, $ca
14511451
$extraData = $this->getDenormalizedData($calendarData);
14521452

14531453
return $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $extraData, $calendarType) {
1454+
// Read the object before overwriting it so the update event can carry
1455+
// both the previous and the new version of the object.
1456+
$oldObjectRow = $this->getCalendarObject($calendarId, $objectUri, $calendarType);
1457+
1458+
$lastModified = time();
14541459
$query = $this->db->getQueryBuilder();
14551460
$query->update('calendarobjects')
14561461
->set('calendardata', $query->createNamedParameter($calendarData, IQueryBuilder::PARAM_LOB))
1457-
->set('lastmodified', $query->createNamedParameter(time()))
1462+
->set('lastmodified', $query->createNamedParameter($lastModified))
14581463
->set('etag', $query->createNamedParameter($extraData['etag']))
14591464
->set('size', $query->createNamedParameter($extraData['size']))
14601465
->set('componenttype', $query->createNamedParameter($extraData['componentType']))
@@ -1470,13 +1475,28 @@ public function updateCalendarObject($calendarId, $objectUri, $calendarData, $ca
14701475
$this->updateProperties($calendarId, $objectUri, $calendarData, $calendarType);
14711476
$this->addChanges($calendarId, [$objectUri], 2, $calendarType);
14721477

1473-
$objectRow = $this->getCalendarObject($calendarId, $objectUri, $calendarType);
1474-
if (is_array($objectRow)) {
1478+
if (is_array($oldObjectRow)) {
1479+
// Derive the new object row from the previous one and the freshly
1480+
// denormalized data instead of querying again, mirroring the columns
1481+
// written above (see rowToCalendarObject()).
1482+
$objectRow = array_merge($oldObjectRow, [
1483+
'uid' => $extraData['uid'],
1484+
'lastmodified' => $lastModified,
1485+
'etag' => '"' . $extraData['etag'] . '"',
1486+
'size' => (int)$extraData['size'],
1487+
'calendardata' => $calendarData,
1488+
'component' => strtolower($extraData['componentType']),
1489+
'classification' => (int)$extraData['classification'],
1490+
]);
1491+
// Refresh the cache populated by the read above so later lookups in
1492+
// this request see the new version instead of the stale one.
1493+
$this->cachedObjects[$calendarId . '::' . $objectUri . '::' . $calendarType] = $objectRow;
1494+
14751495
if ($calendarType === self::CALENDAR_TYPE_CALENDAR) {
14761496
$calendarRow = $this->getCalendarById($calendarId);
14771497
$shares = $this->getShares($calendarId);
14781498

1479-
$this->dispatcher->dispatchTyped(new CalendarObjectUpdatedEvent($calendarId, $calendarRow, $shares, $objectRow));
1499+
$this->dispatcher->dispatchTyped(new CalendarObjectUpdatedEvent($calendarId, $calendarRow, $shares, $objectRow, $oldObjectRow));
14801500
} elseif ($calendarType === self::CALENDAR_TYPE_SUBSCRIPTION) {
14811501
$subscriptionRow = $this->getSubscriptionById($calendarId);
14821502

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\DAV\Listener;
11+
12+
use OCA\DAV\CalDAV\Proxy\ProxyMapper;
13+
use OCA\DAV\CalDAV\Schedule\IMipService;
14+
use OCP\Calendar\Events\CalendarObjectCreatedEvent;
15+
use OCP\Calendar\Events\CalendarObjectDeletedEvent;
16+
use OCP\Calendar\Events\CalendarObjectMovedToTrashEvent;
17+
use OCP\Calendar\Events\CalendarObjectRestoredEvent;
18+
use OCP\Calendar\Events\CalendarObjectUpdatedEvent;
19+
use OCP\EventDispatcher\Event;
20+
use OCP\EventDispatcher\IEventListener;
21+
use OCP\IUser;
22+
use OCP\IUserManager;
23+
use OCP\IUserSession;
24+
use OCP\L10N\IFactory as IL10NFactory;
25+
use OCP\Mail\IMailer;
26+
use OCP\Util;
27+
use Psr\Log\LoggerInterface;
28+
use Sabre\VObject\Component\VCalendar;
29+
use Sabre\VObject\Component\VEvent;
30+
use Sabre\VObject\Reader;
31+
use Throwable;
32+
33+
/**
34+
* Sends an iMIP-style notification email to a calendar owner whenever one
35+
* of their calendar-proxy delegates creates, modifies, deletes, trashes,
36+
* or restores an event on their behalf.
37+
*
38+
* The email body is built with IMipService so the owner sees the same rich
39+
* bullet-list rendering used for regular invitations, including diff
40+
* strike-throughs on update.
41+
*
42+
* @template-implements IEventListener<CalendarObjectCreatedEvent|CalendarObjectUpdatedEvent|CalendarObjectDeletedEvent|CalendarObjectMovedToTrashEvent|CalendarObjectRestoredEvent>
43+
*/
44+
class CalendarDelegateActionListener implements IEventListener {
45+
46+
private const ACTION_CREATE = 'create';
47+
private const ACTION_UPDATE = 'update';
48+
private const ACTION_DELETE = 'delete';
49+
private const ACTION_TRASH = 'trash';
50+
private const ACTION_RESTORE = 'restore';
51+
52+
public function __construct(
53+
private readonly IUserSession $userSession,
54+
private readonly IUserManager $userManager,
55+
private readonly ProxyMapper $proxyMapper,
56+
private readonly IMailer $mailer,
57+
private readonly IL10NFactory $l10nFactory,
58+
private readonly IMipService $imipService,
59+
private readonly LoggerInterface $logger,
60+
) {
61+
}
62+
63+
#[\Override]
64+
public function handle(Event $event): void {
65+
$action = match (true) {
66+
$event instanceof CalendarObjectCreatedEvent => self::ACTION_CREATE,
67+
$event instanceof CalendarObjectUpdatedEvent => self::ACTION_UPDATE,
68+
$event instanceof CalendarObjectDeletedEvent => self::ACTION_DELETE,
69+
$event instanceof CalendarObjectMovedToTrashEvent => self::ACTION_TRASH,
70+
$event instanceof CalendarObjectRestoredEvent => self::ACTION_RESTORE,
71+
default => null,
72+
};
73+
if ($action === null) {
74+
return;
75+
}
76+
77+
$actor = $this->userSession->getUser();
78+
if ($actor === null) {
79+
return;
80+
}
81+
82+
$calendarInfo = $event->getCalendarData();
83+
$ownerPrincipalUri = $calendarInfo['principaluri'] ?? null;
84+
if (!is_string($ownerPrincipalUri) || !str_starts_with($ownerPrincipalUri, 'principals/users/')) {
85+
return;
86+
}
87+
88+
[, $ownerUid] = \Sabre\Uri\split($ownerPrincipalUri);
89+
if ($ownerUid === $actor->getUID()) {
90+
return;
91+
}
92+
93+
if (!$this->actorIsProxyOf($actor->getUID(), $ownerPrincipalUri)) {
94+
return;
95+
}
96+
97+
$owner = $this->userManager->get($ownerUid);
98+
if ($owner === null) {
99+
return;
100+
}
101+
$ownerEmail = $owner->getEMailAddress();
102+
if ($ownerEmail === null || $ownerEmail === '') {
103+
return;
104+
}
105+
106+
// Only an update carries a meaningful previous version to diff against.
107+
$oldObjectData = $event instanceof CalendarObjectUpdatedEvent ? $event->getOldObjectData() : [];
108+
109+
try {
110+
$this->sendNotification($action, $actor, $owner, $ownerEmail, $calendarInfo, $event->getObjectData(), $oldObjectData);
111+
} catch (Throwable $e) {
112+
$this->logger->warning('Could not send delegate-action notification to calendar owner', [
113+
'app' => 'dav',
114+
'owner' => $ownerUid,
115+
'actor' => $actor->getUID(),
116+
'action' => $action,
117+
'exception' => $e,
118+
]);
119+
}
120+
}
121+
122+
private function actorIsProxyOf(string $actorUid, string $ownerPrincipalUri): bool {
123+
$actorPrincipalUri = 'principals/users/' . $actorUid;
124+
foreach ($this->proxyMapper->getProxiesOf($ownerPrincipalUri) as $proxy) {
125+
if ($proxy->getProxyId() === $actorPrincipalUri) {
126+
return true;
127+
}
128+
}
129+
return false;
130+
}
131+
132+
private function sendNotification(
133+
string $action,
134+
IUser $actor,
135+
IUser $owner,
136+
string $ownerEmail,
137+
array $calendarInfo,
138+
array $objectData,
139+
array $oldObjectData,
140+
): void {
141+
$l = $this->l10nFactory->get('dav', $this->l10nFactory->getUserLanguage($owner));
142+
143+
$newVCalendar = $this->readVCalendar($objectData['calendardata'] ?? null);
144+
$newVEvent = $this->firstVEvent($newVCalendar);
145+
if ($newVEvent === null) {
146+
// Without a VEVENT there is nothing meaningful to describe.
147+
return;
148+
}
149+
150+
$oldVCalendar = $this->readVCalendar($oldObjectData['calendardata'] ?? null);
151+
$oldVEvent = $this->firstVEvent($oldVCalendar);
152+
153+
$actorName = $actor->getDisplayName() ?: $actor->getUID();
154+
$calendarName = (string)($calendarInfo['{DAV:}displayname'] ?? $calendarInfo['uri'] ?? 'calendar');
155+
156+
// Build the same data payload IMipPlugin uses, so addBulletList renders
157+
// the familiar title/when/location/url/description list — with diff
158+
// strikethroughs when an old version is available.
159+
$isCancellation = $action === self::ACTION_DELETE || $action === self::ACTION_TRASH;
160+
$data = $isCancellation
161+
? $this->imipService->buildCancelledBodyData($newVEvent)
162+
: $this->imipService->buildBodyData($newVEvent, $action === self::ACTION_UPDATE ? $oldVEvent : null);
163+
164+
$summary = (string)($newVEvent->SUMMARY ?? $l->t('Untitled event'));
165+
166+
[$subject, $heading] = $this->subjectAndHeading($l, $action, $actorName, $summary, $calendarName);
167+
168+
$template = $this->mailer->createEMailTemplate('dav.delegateAction.' . $action, [
169+
'actor' => $actorName,
170+
'calendar' => $calendarName,
171+
'event' => $summary,
172+
]);
173+
$template->addHeader();
174+
$template->setSubject($subject);
175+
$template->addHeading($heading);
176+
177+
// Attribution row (who did it, on which calendar) — sits above the
178+
// event details so the owner immediately sees the responsible delegate.
179+
$template->addBodyListItem($actorName, $l->t('Delegate:'));
180+
$template->addBodyListItem($calendarName, $l->t('Calendar:'));
181+
182+
$this->imipService->addBulletList($template, $newVEvent, $data);
183+
184+
$template->addFooter();
185+
186+
$message = $this->mailer->createMessage();
187+
$message->setFrom([Util::getDefaultEmailAddress('invitations-noreply') => $actorName]);
188+
$message->setTo([$ownerEmail => $owner->getDisplayName() ?: $owner->getUID()]);
189+
$message->setSubject($subject);
190+
$message->useTemplate($template);
191+
192+
// Attach the raw iCalendar so the owner's client can pick up the change.
193+
if ($action !== self::ACTION_DELETE) {
194+
$calendarData = $objectData['calendardata'] ?? null;
195+
if (is_resource($calendarData)) {
196+
$calendarData = stream_get_contents($calendarData);
197+
}
198+
if (is_string($calendarData) && $calendarData !== '') {
199+
$message->attachInline(
200+
$calendarData,
201+
'event.ics',
202+
'text/calendar; charset="utf-8"',
203+
);
204+
}
205+
}
206+
207+
$this->mailer->send($message);
208+
}
209+
210+
/**
211+
* @return array{0: string, 1: string} [subject, heading]
212+
*/
213+
private function subjectAndHeading(\OCP\IL10N $l, string $action, string $actorName, string $summary, string $calendarName): array {
214+
return match ($action) {
215+
self::ACTION_CREATE => [
216+
$l->t('%1$s created "%2$s" on your behalf', [$actorName, $summary]),
217+
$l->t('%1$s created "%2$s" on your calendar "%3$s"', [$actorName, $summary, $calendarName]),
218+
],
219+
self::ACTION_UPDATE => [
220+
$l->t('%1$s updated "%2$s" on your behalf', [$actorName, $summary]),
221+
$l->t('%1$s updated "%2$s" on your calendar "%3$s"', [$actorName, $summary, $calendarName]),
222+
],
223+
self::ACTION_DELETE => [
224+
$l->t('%1$s deleted "%2$s" on your behalf', [$actorName, $summary]),
225+
$l->t('%1$s permanently deleted "%2$s" from your calendar "%3$s"', [$actorName, $summary, $calendarName]),
226+
],
227+
self::ACTION_TRASH => [
228+
$l->t('%1$s moved "%2$s" to the trash on your behalf', [$actorName, $summary]),
229+
$l->t('%1$s moved "%2$s" to the trash on your calendar "%3$s"', [$actorName, $summary, $calendarName]),
230+
],
231+
self::ACTION_RESTORE => [
232+
$l->t('%1$s restored "%2$s" on your behalf', [$actorName, $summary]),
233+
$l->t('%1$s restored "%2$s" on your calendar "%3$s"', [$actorName, $summary, $calendarName]),
234+
],
235+
};
236+
}
237+
238+
private function readVCalendar(mixed $calendarData): ?VCalendar {
239+
if (is_resource($calendarData)) {
240+
$calendarData = stream_get_contents($calendarData);
241+
}
242+
if (!is_string($calendarData) || $calendarData === '') {
243+
return null;
244+
}
245+
try {
246+
$vCalendar = Reader::read($calendarData);
247+
} catch (Throwable) {
248+
return null;
249+
}
250+
return $vCalendar instanceof VCalendar ? $vCalendar : null;
251+
}
252+
253+
private function firstVEvent(?VCalendar $vCalendar): ?VEvent {
254+
if ($vCalendar === null) {
255+
return null;
256+
}
257+
foreach ($vCalendar->VEVENT ?? [] as $vEvent) {
258+
if ($vEvent instanceof VEvent) {
259+
return $vEvent;
260+
}
261+
}
262+
return null;
263+
}
264+
}

lib/public/Calendar/Events/CalendarObjectUpdatedEvent.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,33 @@
1212
* @since 32.0.0
1313
*/
1414
class CalendarObjectUpdatedEvent extends AbstractCalendarObjectEvent {
15+
16+
/**
17+
* @param int $calendarId
18+
* @param array $calendarData
19+
* @param array $shares
20+
* @param array $objectData The object data after the update
21+
* @param array $oldObjectData The object data before the update, in the same
22+
* shape as $objectData (empty when unavailable)
23+
* @since 32.0.0
24+
*/
25+
public function __construct(
26+
int $calendarId,
27+
array $calendarData,
28+
array $shares,
29+
array $objectData,
30+
private array $oldObjectData = [],
31+
) {
32+
parent::__construct($calendarId, $calendarData, $shares, $objectData);
33+
}
34+
35+
/**
36+
* Returns the object data as it was before the update.
37+
*
38+
* @return array
39+
* @since 35.0.0
40+
*/
41+
public function getOldObjectData(): array {
42+
return $this->oldObjectData;
43+
}
1544
}

0 commit comments

Comments
 (0)