diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml
index 0136e86ba3e7d..9d1429471d35f 100644
--- a/apps/dav/appinfo/info.xml
+++ b/apps/dav/appinfo/info.xml
@@ -10,7 +10,7 @@
WebDAV
WebDAV endpoint
WebDAV endpoint
- 3.0.0-dev.0
+ 3.0.0-dev.1
agpl
owncloud.org
DAV
diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php
index fbe1e27bbc5c4..310f1ec83be3d 100644
--- a/apps/dav/composer/composer/autoload_classmap.php
+++ b/apps/dav/composer/composer/autoload_classmap.php
@@ -415,6 +415,7 @@
'OCA\\DAV\\Migration\\Version1038Date20260302000000' => $baseDir . '/../lib/Migration/Version1038Date20260302000000.php',
'OCA\\DAV\\Migration\\Version1039Date20260408000000' => $baseDir . '/../lib/Migration/Version1039Date20260408000000.php',
'OCA\\DAV\\Migration\\Version1040Date20260805000000' => $baseDir . '/../lib/Migration/Version1040Date20260805000000.php',
+ 'OCA\\DAV\\Migration\\Version3000Date20260828000000' => $baseDir . '/../lib/Migration/Version3000Date20260828000000.php',
'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php',
'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php',
diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php
index 5e0385de4eddc..98a49d46284dd 100644
--- a/apps/dav/composer/composer/autoload_static.php
+++ b/apps/dav/composer/composer/autoload_static.php
@@ -430,6 +430,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Migration\\Version1038Date20260302000000' => __DIR__ . '/..' . '/../lib/Migration/Version1038Date20260302000000.php',
'OCA\\DAV\\Migration\\Version1039Date20260408000000' => __DIR__ . '/..' . '/../lib/Migration/Version1039Date20260408000000.php',
'OCA\\DAV\\Migration\\Version1040Date20260805000000' => __DIR__ . '/..' . '/../lib/Migration/Version1040Date20260805000000.php',
+ 'OCA\\DAV\\Migration\\Version3000Date20260828000000' => __DIR__ . '/..' . '/../lib/Migration/Version3000Date20260828000000.php',
'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php',
'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php',
diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php
index b3b9ebf4238bd..c6e8c9c54f497 100644
--- a/apps/dav/lib/CalDAV/CalDavBackend.php
+++ b/apps/dav/lib/CalDAV/CalDavBackend.php
@@ -153,6 +153,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => ['deleted_at', 'int'],
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}default-alarm-part-day' => ['default_alarm_pday', 'int'],
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}default-alarm-full-day' => ['default_alarm_fday', 'int'],
+ '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}disable-alarm-notifications' => ['disable_alarm_notifications', 'bool'],
];
/**
diff --git a/apps/dav/lib/CalDAV/Reminder/Backend.php b/apps/dav/lib/CalDAV/Reminder/Backend.php
index 5ea4566fa4c36..8f5e47cb8ab04 100644
--- a/apps/dav/lib/CalDAV/Reminder/Backend.php
+++ b/apps/dav/lib/CalDAV/Reminder/Backend.php
@@ -9,8 +9,11 @@
namespace OCA\DAV\CalDAV\Reminder;
+use OCA\DAV\DAV\Sharing\Plugin;
+use OCA\DAV\Db\PropertyMapper;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IDBConnection;
+use Sabre\Uri;
/**
* Class Backend
@@ -19,15 +22,23 @@
*/
class Backend {
+ /**
+ * WebDAV property a sharee sets on their own view of a shared calendar
+ * to mute reminders, stored via the generic custom-properties backend.
+ */
+ private const string PROPERTY_DISABLE_ALARM_NOTIFICATIONS = '{' . Plugin::NS_NEXTCLOUD . '}disable-alarm-notifications';
+
/**
* Backend constructor.
*
* @param IDBConnection $db
* @param ITimeFactory $timeFactory
+ * @param PropertyMapper $propertyMapper
*/
public function __construct(
protected IDBConnection $db,
protected ITimeFactory $timeFactory,
+ private PropertyMapper $propertyMapper,
) {
}
@@ -39,12 +50,12 @@ public function __construct(
*/
public function getRemindersToProcess():array {
$query = $this->db->getQueryBuilder();
- $query->select(['cr.id', 'cr.calendar_id','cr.object_id','cr.is_recurring','cr.uid','cr.recurrence_id','cr.is_recurrence_exception','cr.event_hash','cr.alarm_hash','cr.type','cr.is_relative','cr.notification_date','cr.is_repeat_based','co.calendardata', 'c.displayname', 'c.principaluri'])
+ $query->select(['cr.id', 'cr.calendar_id','cr.object_id','cr.is_recurring','cr.uid','cr.recurrence_id','cr.is_recurrence_exception','cr.event_hash','cr.alarm_hash','cr.type','cr.is_relative','cr.notification_date','cr.is_repeat_based','co.calendardata', 'c.displayname', 'c.principaluri', 'c.uri', 'c.disable_alarm_notifications'])
->from('calendar_reminders', 'cr')
->where($query->expr()->lte('cr.notification_date', $query->createNamedParameter($this->timeFactory->getTime())))
->join('cr', 'calendarobjects', 'co', $query->expr()->eq('cr.object_id', 'co.id'))
->join('cr', 'calendars', 'c', $query->expr()->eq('cr.calendar_id', 'c.id'))
- ->groupBy('cr.event_hash', 'cr.notification_date', 'cr.type', 'cr.id', 'cr.calendar_id', 'cr.object_id', 'cr.is_recurring', 'cr.uid', 'cr.recurrence_id', 'cr.is_recurrence_exception', 'cr.alarm_hash', 'cr.is_relative', 'cr.is_repeat_based', 'co.calendardata', 'c.displayname', 'c.principaluri');
+ ->groupBy('cr.event_hash', 'cr.notification_date', 'cr.type', 'cr.id', 'cr.calendar_id', 'cr.object_id', 'cr.is_recurring', 'cr.uid', 'cr.recurrence_id', 'cr.is_recurrence_exception', 'cr.alarm_hash', 'cr.is_relative', 'cr.is_repeat_based', 'co.calendardata', 'c.displayname', 'c.principaluri', 'c.uri', 'c.disable_alarm_notifications');
$stmt = $query->executeQuery();
return array_map(
@@ -53,6 +64,35 @@ public function getRemindersToProcess():array {
);
}
+ /**
+ * Whether the given principal has muted reminders for this reminder's calendar.
+ *
+ * The calendar owner's setting is stored directly on the `calendars` row (already
+ * part of $reminder). A sharee's setting is their own WebDAV property on their view
+ * of the shared calendar, stored via the generic custom-properties backend.
+ *
+ * @param array $reminder A row as returned by getRemindersToProcess()
+ */
+ public function isReminderMutedForPrincipal(array $reminder, string $principalUri): bool {
+ if ($principalUri === $reminder['principaluri']) {
+ return $reminder['disable_alarm_notifications'];
+ }
+
+ [, $shareeUid] = Uri\split($principalUri);
+ [, $ownerUid] = Uri\split($reminder['principaluri']);
+ if ($shareeUid === null || $ownerUid === null) {
+ return false;
+ }
+
+ $path = 'calendars/' . $shareeUid . '/' . $reminder['uri'] . '_shared_by_' . $ownerUid;
+ $properties = $this->propertyMapper->findPropertyByPathAndName($shareeUid, $path, self::PROPERTY_DISABLE_ALARM_NOTIFICATIONS);
+ if (empty($properties)) {
+ return false;
+ }
+
+ return $properties[0]->getPropertyvalue() === '1';
+ }
+
/**
* Get all scheduled reminders for an event
*
@@ -192,6 +232,9 @@ private function fixRowTyping(array $row): array {
$row['is_relative'] = (bool)$row['is_relative'];
$row['notification_date'] = (int)$row['notification_date'];
$row['is_repeat_based'] = (bool)$row['is_repeat_based'];
+ if (array_key_exists('disable_alarm_notifications', $row)) {
+ $row['disable_alarm_notifications'] = (bool)$row['disable_alarm_notifications'];
+ }
return $row;
}
diff --git a/apps/dav/lib/CalDAV/Reminder/ReminderService.php b/apps/dav/lib/CalDAV/Reminder/ReminderService.php
index ef480a310b2b6..bc163fb9815ed 100644
--- a/apps/dav/lib/CalDAV/Reminder/ReminderService.php
+++ b/apps/dav/lib/CalDAV/Reminder/ReminderService.php
@@ -123,15 +123,30 @@ public function processReminders() :void {
continue;
}
- if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedUsers', 'yes') === 'no') {
+ if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedUsers', 'yes') === 'yes') {
$users = $this->getAllUsersWithWriteAccessToCalendar($reminder['calendar_id']);
} else {
$users = [];
}
- $user = $this->getUserFromPrincipalURI($reminder['principaluri']);
- if ($user) {
- $users[] = $user;
+ $ownerUser = $this->getUserFromPrincipalURI($reminder['principaluri']);
+ if ($ownerUser !== null) {
+ $users[] = $ownerUser;
+ }
+
+ // Filter out any users who have muted reminders for this calendar
+ $users = array_values(array_filter($users, function (IUser $u) use ($reminder): bool {
+ $principalUri = 'principals/users/' . $u->getUID();
+ return !$this->backend->isReminderMutedForPrincipal($reminder, $principalUri);
+ }));
+
+ if (count($users) === 0) {
+ $this->logger->debug('Reminder {id} is ignored by all recipient users for calendar {calendarId}', [
+ 'id' => $reminder['id'],
+ 'calendarId' => $reminder['calendar_id'],
+ ]);
+ $this->deleteOrProcessNext($reminder, $vevent);
+ continue;
}
$userPrincipalEmailAddresses = [];
diff --git a/apps/dav/lib/DAV/CustomPropertiesBackend.php b/apps/dav/lib/DAV/CustomPropertiesBackend.php
index 3a95fe07606ca..101132f1c3d5e 100644
--- a/apps/dav/lib/DAV/CustomPropertiesBackend.php
+++ b/apps/dav/lib/DAV/CustomPropertiesBackend.php
@@ -195,6 +195,7 @@ public function propFind($path, PropFind $propFind): void {
'{http://apple.com/ns/ical/}calendar-order',
'{http://apple.com/ns/ical/}calendar-color',
'{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp',
+ '{http://nextcloud.com/ns}disable-alarm-notifications',
];
foreach ($customPropertiesForShares as $customPropertyForShares) {
diff --git a/apps/dav/lib/Migration/Version3000Date20260828000000.php b/apps/dav/lib/Migration/Version3000Date20260828000000.php
new file mode 100644
index 0000000000000..4d53ede02bd1a
--- /dev/null
+++ b/apps/dav/lib/Migration/Version3000Date20260828000000.php
@@ -0,0 +1,40 @@
+getTable('calendars');
+ if (!$calendarsTable->hasColumn('disable_alarm_notifications')) {
+ $calendarsTable->addColumn('disable_alarm_notifications', Types::BOOLEAN, [
+ 'notnull' => false,
+ 'default' => false,
+ ]);
+ $modified = true;
+ }
+
+ return $modified ? $schema : null;
+ }
+}
diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
index a45ee73cd2e06..6fee8f696fd6f 100644
--- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
+++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
@@ -2478,4 +2478,35 @@ public function testGetFederatedCalendarByUriHidesPendingCalendar(): void {
$this->assertNull($this->backend->getFederatedCalendarByUri(self::UNIT_TEST_USER, 'federated-cal'));
}
+
+ public function testDisableAlarmNotificationsProperty(): void {
+ $calendarId = $this->backend->createCalendar(self::UNIT_TEST_USER, 'DisableAlarmNotificationsTest', []);
+
+ // Default should be false
+ $calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER);
+ $this->assertFalse((bool)($calendars[0]['{http://nextcloud.com/ns}disable-alarm-notifications'] ?? false));
+
+ // Update to true ('1')
+ $patch = new PropPatch([
+ '{http://nextcloud.com/ns}disable-alarm-notifications' => '1',
+ ]);
+ $this->backend->updateCalendar($calendarId, $patch);
+ $patch->commit();
+
+ $calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER);
+ $this->assertTrue((bool)($calendars[0]['{http://nextcloud.com/ns}disable-alarm-notifications'] ?? false));
+
+ // Update to false ('0')
+ $patch = new PropPatch([
+ '{http://nextcloud.com/ns}disable-alarm-notifications' => '0',
+ ]);
+ $this->backend->updateCalendar($calendarId, $patch);
+ $patch->commit();
+
+ $calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER);
+ $this->assertFalse((bool)($calendars[0]['{http://nextcloud.com/ns}disable-alarm-notifications'] ?? false));
+
+ // Clean up
+ $this->backend->deleteCalendar($calendars[0]['id'], true);
+ }
}
diff --git a/apps/dav/tests/unit/CalDAV/Reminder/BackendTest.php b/apps/dav/tests/unit/CalDAV/Reminder/BackendTest.php
index f29b88c5085da..59c55d9bbb3cd 100644
--- a/apps/dav/tests/unit/CalDAV/Reminder/BackendTest.php
+++ b/apps/dav/tests/unit/CalDAV/Reminder/BackendTest.php
@@ -10,7 +10,9 @@
namespace OCA\DAV\Tests\unit\CalDAV\Reminder;
use OCA\DAV\CalDAV\Reminder\Backend as ReminderBackend;
+use OCA\DAV\Db\PropertyMapper;
use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\DB\QueryBuilder\IQueryBuilder;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
@@ -25,9 +27,10 @@ protected function setUp(): void {
$query->delete('calendar_reminders')->executeStatement();
$query->delete('calendarobjects')->executeStatement();
$query->delete('calendars')->executeStatement();
+ $query->delete('properties')->executeStatement();
$this->timeFactory = $this->createMock(ITimeFactory::class);
- $this->reminderBackend = new ReminderBackend(self::$realDatabase, $this->timeFactory);
+ $this->reminderBackend = new ReminderBackend(self::$realDatabase, $this->timeFactory, new PropertyMapper(self::$realDatabase));
$this->createRemindersTestSet();
}
@@ -37,6 +40,7 @@ protected function tearDown(): void {
$query->delete('calendar_reminders')->executeStatement();
$query->delete('calendarobjects')->executeStatement();
$query->delete('calendars')->executeStatement();
+ $query->delete('properties')->executeStatement();
parent::tearDown();
}
@@ -129,6 +133,8 @@ public function testGetRemindersToProcess(): void {
'calendardata' => 'Calendar data 123',
'displayname' => 'Displayname 123',
'principaluri' => 'principals/users/user001',
+ 'uri' => 'personal',
+ 'disable_alarm_notifications' => false,
];
$expected2 = [
'calendar_id' => 1,
@@ -146,6 +152,8 @@ public function testGetRemindersToProcess(): void {
'calendardata' => 'Calendar data 123',
'displayname' => 'Displayname 123',
'principaluri' => 'principals/users/user001',
+ 'uri' => 'personal',
+ 'disable_alarm_notifications' => false,
];
$this->assertEqualsCanonicalizing([$rows[0],$rows[1]], [$expected1,$expected2]);
@@ -252,6 +260,50 @@ public function testUpdateReminder(): void {
$this->assertEquals((int)$row['notification_date'], 123700);
}
+ public function testIsReminderMutedForPrincipalUsesCalendarColumnForOwner(): void {
+ $reminder = [
+ 'principaluri' => 'principals/users/user001',
+ 'uri' => 'personal',
+ 'disable_alarm_notifications' => true,
+ ];
+
+ $this->assertTrue($this->reminderBackend->isReminderMutedForPrincipal($reminder, 'principals/users/user001'));
+ }
+
+ public function testIsReminderMutedForPrincipalIsFalseForShareeWithoutProperty(): void {
+ $reminder = [
+ 'principaluri' => 'principals/users/user001',
+ 'uri' => 'personal',
+ 'disable_alarm_notifications' => false,
+ ];
+
+ $this->assertFalse($this->reminderBackend->isReminderMutedForPrincipal($reminder, 'principals/users/user002'));
+ }
+
+ public function testIsReminderMutedForPrincipalReadsShareePropertyOverride(): void {
+ $reminder = [
+ 'principaluri' => 'principals/users/user001',
+ 'uri' => 'personal',
+ // Owner has not muted their own calendar ...
+ 'disable_alarm_notifications' => false,
+ ];
+ // ... but the sharee muted their own view of it.
+ $path = 'calendars/user002/personal_shared_by_user001';
+
+ $query = self::$realDatabase->getQueryBuilder();
+ $query->insert('properties')
+ ->values([
+ 'userid' => $query->createNamedParameter('user002'),
+ 'propertypath' => $query->createNamedParameter($path),
+ 'propertyname' => $query->createNamedParameter('{http://nextcloud.com/ns}disable-alarm-notifications'),
+ 'propertyvalue' => $query->createNamedParameter('1'),
+ 'valuetype' => $query->createNamedParameter(1, IQueryBuilder::PARAM_INT),
+ ])
+ ->executeStatement();
+
+ $this->assertTrue($this->reminderBackend->isReminderMutedForPrincipal($reminder, 'principals/users/user002'));
+ }
+
private function createRemindersTestSet(): void {
$query = self::$realDatabase->getQueryBuilder();
$query->insert('calendars')
@@ -259,6 +311,7 @@ private function createRemindersTestSet(): void {
'id' => $query->createNamedParameter(1),
'principaluri' => $query->createNamedParameter('principals/users/user001'),
'displayname' => $query->createNamedParameter('Displayname 123'),
+ 'uri' => $query->createNamedParameter('personal'),
])
->executeStatement();
@@ -268,6 +321,7 @@ private function createRemindersTestSet(): void {
'id' => $query->createNamedParameter(99),
'principaluri' => $query->createNamedParameter('principals/users/user002'),
'displayname' => $query->createNamedParameter('Displayname 99'),
+ 'uri' => $query->createNamedParameter('personal'),
])
->executeStatement();
diff --git a/apps/dav/tests/unit/CalDAV/Reminder/ReminderServiceTest.php b/apps/dav/tests/unit/CalDAV/Reminder/ReminderServiceTest.php
index f07d508e61978..0fde892b53dba 100644
--- a/apps/dav/tests/unit/CalDAV/Reminder/ReminderServiceTest.php
+++ b/apps/dav/tests/unit/CalDAV/Reminder/ReminderServiceTest.php
@@ -774,4 +774,55 @@ public function testProcessReminders():void {
$this->reminderService->processReminders();
}
+
+ public function testProcessRemindersSkipsMutedRecipient():void {
+ $reminder = [
+ 'id' => 1,
+ 'calendar_id' => 1337,
+ 'object_id' => 42,
+ 'uid' => 'wej2z68l9h',
+ 'is_recurring' => false,
+ 'recurrence_id' => 1465430400,
+ 'is_recurrence_exception' => false,
+ 'event_hash' => '5c70531aab15c92b52518ae10a2f78a4',
+ 'alarm_hash' => 'de919af7429d3b5c11e8b9d289b411a6',
+ 'type' => 'EMAIL',
+ 'is_relative' => true,
+ 'notification_date' => 1465429500,
+ 'is_repeat_based' => false,
+ 'calendardata' => self::CALENDAR_DATA,
+ 'displayname' => 'Displayname 123',
+ 'principaluri' => 'principals/users/user001',
+ ];
+
+ $this->backend->expects($this->once())
+ ->method('getRemindersToProcess')
+ ->willReturn([$reminder]);
+
+ $this->notificationProviderManager->expects($this->once())
+ ->method('hasProvider')
+ ->with('EMAIL')
+ ->willReturn(true);
+
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('user001');
+ $this->userManager->expects($this->once())
+ ->method('get')
+ ->with('user001')
+ ->willReturn($user);
+
+ $this->backend->expects($this->once())
+ ->method('isReminderMutedForPrincipal')
+ ->with($reminder, 'principals/users/user001')
+ ->willReturn(true);
+
+ $this->notificationProviderManager->expects($this->never())
+ ->method('getProvider');
+
+ $this->backend->expects($this->once())
+ ->method('removeReminder')
+ ->with(1);
+
+ $this->reminderService->processReminders();
+ }
}