From d3fcc7177b8634867b8de080cfad1225b16177a5 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Mon, 22 Dec 2025 20:51:09 -0500 Subject: [PATCH] feat: restrict calendar invitation participants Signed-off-by: SebastianKrupinski --- apps/dav/lib/CalDAV/Schedule/IMipPlugin.php | 11 + apps/dav/lib/CalDAV/Schedule/IMipService.php | 10 + .../unit/CalDAV/Schedule/IMipPluginTest.php | 202 +++++++++++++++++- .../unit/CalDAV/Schedule/IMipServiceTest.php | 25 +++ 4 files changed, 245 insertions(+), 3 deletions(-) diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php index 8e3e4c1b0736d..5acaee8d25245 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php @@ -126,6 +126,17 @@ public function schedule(Message $iTipMessage) { $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; return; } + + // Check if external attendees are disabled + $externalAttendeesDisabled = $this->config->getValueBool('dav', 'caldav.external_attendees_disabled', false); + if ($externalAttendeesDisabled && !$this->imipService->isSystemUser($recipient)) { + $this->logger->debug('No invitation sent to external attendee (external attendees disabled)', [ + 'attendee' => $recipient, + ]); + $iTipMessage->scheduleStatus = '5.0; External attendees are disabled'; + return; + } + $recipientName = $iTipMessage->recipientName ? (string)$iTipMessage->recipientName : null; $newEvents = $iTipMessage->message; diff --git a/apps/dav/lib/CalDAV/Schedule/IMipService.php b/apps/dav/lib/CalDAV/Schedule/IMipService.php index b091ea19ba133..b8d0724555200 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipService.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipService.php @@ -875,6 +875,16 @@ public function getLastOccurrence(VCalendar $vObject) { return $dtStart->getDateTime()->getTimeStamp(); } + /** + * Check if an email address belongs to a system user + * + * @param string $email + * @return bool True if the email belongs to a system user, false otherwise + */ + public function isSystemUser(string $email): bool { + return !empty($this->userManager->getByEmail($email)); + } + /** * @param Property $attendee */ diff --git a/apps/dav/tests/unit/CalDAV/Schedule/IMipPluginTest.php b/apps/dav/tests/unit/CalDAV/Schedule/IMipPluginTest.php index e7dca1372741a..3a179d331d37d 100644 --- a/apps/dav/tests/unit/CalDAV/Schedule/IMipPluginTest.php +++ b/apps/dav/tests/unit/CalDAV/Schedule/IMipPluginTest.php @@ -130,6 +130,10 @@ public function testDeliveryNoSignificantChange(): void { $message->senderName = 'Mr. Wizard'; $message->recipient = 'mailto:' . 'frodo@hobb.it'; $message->significantChange = false; + + $this->config->expects(self::never()) + ->method('getValueBool'); + $this->plugin->schedule($message); $this->assertEquals('1.0', $message->getScheduleStatus()); } @@ -177,6 +181,12 @@ public function testParsingSingle(): void { $this->service->expects(self::once()) ->method('getLastOccurrence') ->willReturn(1496912700); + $this->config->expects(self::exactly(2)) + ->method('getValueBool') + ->willReturnMap([ + ['dav', 'caldav.external_attendees_disabled', false, false], + ['core', 'mail_providers_enabled', true, false], + ]); $this->eventComparisonService->expects(self::once()) ->method('findModified') ->willReturn(['new' => [$newVevent], 'old' => [$oldVEvent]]); @@ -280,6 +290,10 @@ public function testAttendeeIsResource(): void { $this->service->expects(self::once()) ->method('getLastOccurrence') ->willReturn(1496912700); + $this->config->expects(self::once()) + ->method('getValueBool') + ->with('dav', 'caldav.external_attendees_disabled', false) + ->willReturn(false); $this->eventComparisonService->expects(self::once()) ->method('findModified') ->willReturn(['new' => [$newVevent], 'old' => [$oldVEvent]]); @@ -354,6 +368,10 @@ public function testAttendeeIsCircle(): void { $this->service->expects(self::once()) ->method('getLastOccurrence') ->willReturn(1496912700); + $this->config->expects(self::once()) + ->method('getValueBool') + ->with('dav', 'caldav.external_attendees_disabled', false) + ->willReturn(false); $this->eventComparisonService->expects(self::once()) ->method('findModified') ->willReturn(['new' => [$newVevent], 'old' => null]); @@ -455,6 +473,12 @@ public function testParsingRecurrence(): void { $this->service->expects(self::once()) ->method('getLastOccurrence') ->willReturn(1496912700); + $this->config->expects(self::exactly(2)) + ->method('getValueBool') + ->willReturnMap([ + ['dav', 'caldav.external_attendees_disabled', false, false], + ['core', 'mail_providers_enabled', true, false], + ]); $this->eventComparisonService->expects(self::once()) ->method('findModified') ->willReturn(['old' => [] ,'new' => [$newVevent]]); @@ -695,6 +719,12 @@ public function testMailProviderSend(): void { $this->service->expects(self::once()) ->method('getLastOccurrence') ->willReturn(1496912700); + $this->config->expects(self::exactly(2)) + ->method('getValueBool') + ->willReturnMap([ + ['dav', 'caldav.external_attendees_disabled', false, false], + ['core', 'mail_providers_enabled', true, true], + ]); $this->service->expects(self::once()) ->method('getCurrentAttendee') ->with($message) @@ -837,10 +867,12 @@ public function testMailProviderDisabled(): void { ->method('getValueString') ->with('dav', 'invitation_link_recipients', 'yes') ->willReturn('yes'); - $this->config->expects(self::once()) + $this->config->expects(self::exactly(2)) ->method('getValueBool') - ->with('core', 'mail_providers_enabled', true) - ->willReturn(false); + ->willReturnMap([ + ['dav', 'caldav.external_attendees_disabled', false, false], + ['core', 'mail_providers_enabled', true, false], + ]); $this->service->expects(self::once()) ->method('createInvitationToken') ->with($message, $newVevent, 1496912700) @@ -888,6 +920,12 @@ public function testNoOldEvent(): void { $this->service->expects(self::once()) ->method('getLastOccurrence') ->willReturn(1496912700); + $this->config->expects(self::exactly(2)) + ->method('getValueBool') + ->willReturnMap([ + ['dav', 'caldav.external_attendees_disabled', false, false], + ['core', 'mail_providers_enabled', true, false], + ]); $this->eventComparisonService->expects(self::once()) ->method('findModified') ->with($newVCalendar, null) @@ -981,6 +1019,12 @@ public function testNoButtons(): void { $this->service->expects(self::once()) ->method('getLastOccurrence') ->willReturn(1496912700); + $this->config->expects(self::exactly(2)) + ->method('getValueBool') + ->willReturnMap([ + ['dav', 'caldav.external_attendees_disabled', false, false], + ['core', 'mail_providers_enabled', true, false], + ]); $this->eventComparisonService->expects(self::once()) ->method('findModified') ->with($newVCalendar, null) @@ -1040,4 +1084,156 @@ public function testNoButtons(): void { $this->plugin->schedule($message); $this->assertEquals('1.1', $message->getScheduleStatus()); } + + public function testExternalAttendeesDisabledForExternalUser(): void { + $message = new Message(); + $message->method = 'REQUEST'; + $newVCalendar = new VCalendar(); + $newVevent = new VEvent($newVCalendar, 'one', array_merge([ + 'UID' => 'uid-1234', + 'SEQUENCE' => 1, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00') + ], [])); + $newVevent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $newVevent->add('ATTENDEE', 'mailto:external@example.com', ['RSVP' => 'TRUE', 'CN' => 'External User']); + $message->message = $newVCalendar; + $message->sender = 'mailto:gandalf@wiz.ard'; + $message->senderName = 'Mr. Wizard'; + $message->recipient = 'mailto:external@example.com'; + + $this->service->expects(self::once()) + ->method('getLastOccurrence') + ->willReturn(1496912700); + $this->config->expects(self::once()) + ->method('getValueBool') + ->with('dav', 'caldav.external_attendees_disabled', false) + ->willReturn(true); + $this->service->expects(self::once()) + ->method('isSystemUser') + ->with('external@example.com') + ->willReturn(false); + $this->eventComparisonService->expects(self::never()) + ->method('findModified'); + $this->service->expects(self::never()) + ->method('getCurrentAttendee'); + $this->mailer->expects(self::never()) + ->method('send'); + + $this->plugin->schedule($message); + $this->assertEquals('5.0', $message->getScheduleStatus()); + } + + public function testExternalAttendeesDisabledForSystemUser(): void { + $message = new Message(); + $message->method = 'REQUEST'; + $newVCalendar = new VCalendar(); + $newVevent = new VEvent($newVCalendar, 'one', array_merge([ + 'UID' => 'uid-1234', + 'SEQUENCE' => 1, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00') + ], [])); + $newVevent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $newVevent->add('ATTENDEE', 'mailto:frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + $message->message = $newVCalendar; + $message->sender = 'mailto:gandalf@wiz.ard'; + $message->senderName = 'Mr. Wizard'; + $message->recipient = 'mailto:frodo@hobb.it'; + + $oldVCalendar = new VCalendar(); + $oldVEvent = new VEvent($oldVCalendar, 'one', [ + 'UID' => 'uid-1234', + 'SEQUENCE' => 0, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00') + ]); + $oldVEvent->add('ORGANIZER', 'mailto:gandalf@wiz.ard'); + $oldVEvent->add('ATTENDEE', 'mailto:frodo@hobb.it', ['RSVP' => 'TRUE', 'CN' => 'Frodo']); + $oldVCalendar->add($oldVEvent); + + $data = ['invitee_name' => 'Mr. Wizard', + 'meeting_title' => 'Fellowship meeting', + 'attendee_name' => 'frodo@hobb.it' + ]; + $attendees = $newVevent->select('ATTENDEE'); + $atnd = ''; + foreach ($attendees as $attendee) { + if (strcasecmp($attendee->getValue(), $message->recipient) === 0) { + $atnd = $attendee; + } + } + $this->plugin->setVCalendar($oldVCalendar); + $this->service->expects(self::once()) + ->method('getLastOccurrence') + ->willReturn(1496912700); + $this->config->expects(self::exactly(2)) + ->method('getValueBool') + ->willReturnMap([ + ['dav', 'caldav.external_attendees_disabled', false, true], + ['core', 'mail_providers_enabled', true, false], + ]); + $this->service->expects(self::once()) + ->method('isSystemUser') + ->with('frodo@hobb.it') + ->willReturn(true); + $this->eventComparisonService->expects(self::once()) + ->method('findModified') + ->willReturn(['new' => [$newVevent], 'old' => [$oldVEvent]]); + $this->service->expects(self::once()) + ->method('getCurrentAttendee') + ->with($message) + ->willReturn($atnd); + $this->service->expects(self::once()) + ->method('isRoomOrResource') + ->with($atnd) + ->willReturn(false); + $this->service->expects(self::once()) + ->method('isCircle') + ->with($atnd) + ->willReturn(false); + $this->service->expects(self::once()) + ->method('buildBodyData') + ->with($newVevent, $oldVEvent) + ->willReturn($data); + $this->user->expects(self::any()) + ->method('getUID') + ->willReturn('user1'); + $this->user->expects(self::any()) + ->method('getDisplayName') + ->willReturn('Mr. Wizard'); + $this->userSession->expects(self::any()) + ->method('getUser') + ->willReturn($this->user); + $this->service->expects(self::once()) + ->method('getFrom'); + $this->service->expects(self::once()) + ->method('addSubjectAndHeading') + ->with($this->emailTemplate, 'request', 'Mr. Wizard', 'Fellowship meeting', true); + $this->service->expects(self::once()) + ->method('addBulletList') + ->with($this->emailTemplate, $newVevent, $data); + $this->service->expects(self::once()) + ->method('getAttendeeRsvpOrReqForParticipant') + ->willReturn(true); + $this->config->expects(self::once()) + ->method('getValueString') + ->with('dav', 'invitation_link_recipients', 'yes') + ->willReturn('yes'); + $this->service->expects(self::once()) + ->method('createInvitationToken') + ->with($message, $newVevent, 1496912700) + ->willReturn('token'); + $this->service->expects(self::once()) + ->method('addResponseButtons') + ->with($this->emailTemplate, 'token'); + $this->service->expects(self::once()) + ->method('addMoreOptionsButton') + ->with($this->emailTemplate, 'token'); + $this->mailer->expects(self::once()) + ->method('send') + ->willReturn([]); + $this->plugin->schedule($message); + $this->assertEquals('1.1', $message->getScheduleStatus()); + } } diff --git a/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php b/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php index 9b288822c0083..7d58930f0700b 100644 --- a/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php +++ b/apps/dav/tests/unit/CalDAV/Schedule/IMipServiceTest.php @@ -161,6 +161,31 @@ public function testGetFrom(): void { $this->assertEquals($expected, $actual); } + public function testIsSystemUserWhenUserExists(): void { + $email = 'user@example.com'; + $user = $this->createMock(\OCP\IUser::class); + + $this->userManager->expects(self::once()) + ->method('getByEmail') + ->with($email) + ->willReturn([$user]); + + $result = $this->service->isSystemUser($email); + $this->assertTrue($result); + } + + public function testIsSystemUserWhenUserDoesNotExist(): void { + $email = 'external@example.com'; + + $this->userManager->expects(self::once()) + ->method('getByEmail') + ->with($email) + ->willReturn([]); + + $result = $this->service->isSystemUser($email); + $this->assertFalse($result); + } + public function testBuildBodyDataCreated(): void { // construct l10n return(s)