diff --git a/appinfo/info.xml b/appinfo/info.xml index 059adc950c..939d0c5758 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -34,7 +34,7 @@ The rating depends on the installed text processing backend. See [the rating ove Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud.com/blog/nextcloud-ethical-ai-rating/). ]]> - 5.7.0-alpha.2 + 5.7.0-alpha.3 agpl Christoph Wurst GretaD diff --git a/lib/Account.php b/lib/Account.php index 15aecc58ac..0503b64c9b 100644 --- a/lib/Account.php +++ b/lib/Account.php @@ -68,6 +68,10 @@ public function getUserId() { public function getDebug(): bool { return $this->account->getDebug(); } + + public function getImipCreate(): bool { + return $this->account->getImipCreate(); + } /** * Set the quota percentage diff --git a/lib/Controller/AccountsController.php b/lib/Controller/AccountsController.php index f91c8f1705..e0bed654c9 100644 --- a/lib/Controller/AccountsController.php +++ b/lib/Controller/AccountsController.php @@ -231,7 +231,9 @@ public function patchAccount(int $id, ?int $trashRetentionDays = null, ?int $junkMailboxId = null, ?bool $searchBody = null, - ?bool $classificationEnabled = null): JSONResponse { + ?bool $classificationEnabled = null, + ?bool $imipCreate = null, + ): JSONResponse { $account = $this->accountService->find($this->currentUserId, $id); $dbAccount = $account->getMailAccount(); @@ -282,6 +284,9 @@ public function patchAccount(int $id, if ($classificationEnabled !== null) { $dbAccount->setClassificationEnabled($classificationEnabled); } + if ($imipCreate !== null) { + $dbAccount->setImipCreate($imipCreate); + } return new JSONResponse( new Account($this->accountService->save($dbAccount)) ); diff --git a/lib/Db/MailAccount.php b/lib/Db/MailAccount.php index 7a6853d0c5..c3642c465b 100644 --- a/lib/Db/MailAccount.php +++ b/lib/Db/MailAccount.php @@ -105,6 +105,8 @@ * @method void setDebug(bool $debug) * @method bool getClassificationEnabled() * @method void setClassificationEnabled(bool $classificationEnabled) + * @method bool getImipCreate() + * @method void setImipCreate(bool $value) */ class MailAccount extends Entity { public const SIGNATURE_MODE_PLAIN = 0; @@ -190,6 +192,8 @@ class MailAccount extends Entity { protected bool $debug = false; protected bool $classificationEnabled = true; + protected bool $imipCreate = false; + /** * @param array $params */ @@ -253,6 +257,9 @@ public function __construct(array $params = []) { if (isset($params['classificationEnabled'])) { $this->setClassificationEnabled($params['classificationEnabled']); } + if (isset($params['imipCreate'])) { + $this->setImipCreate($params['imipCreate']); + } $this->addType('inboundPort', 'integer'); $this->addType('outboundPort', 'integer'); @@ -278,6 +285,7 @@ public function __construct(array $params = []) { $this->addType('oooFollowsSystem', 'boolean'); $this->addType('debug', 'boolean'); $this->addType('classificationEnabled', 'boolean'); + $this->addType('imipCreate', 'boolean'); } public function getOutOfOfficeFollowsSystem(): bool { @@ -327,6 +335,7 @@ public function toJson() { 'outOfOfficeFollowsSystem' => $this->getOutOfOfficeFollowsSystem(), 'debug' => $this->getDebug(), 'classificationEnabled' => $this->getClassificationEnabled(), + 'imipCreate' => $this->getImipCreate(), ]; if (!is_null($this->getOutboundHost())) { diff --git a/lib/Migration/Version5007Date20251208000000.php b/lib/Migration/Version5007Date20251208000000.php new file mode 100644 index 0000000000..56be44cf14 --- /dev/null +++ b/lib/Migration/Version5007Date20251208000000.php @@ -0,0 +1,37 @@ +getTable('mail_accounts'); + if (!$accountsTable->hasColumn('imip_create')) { + $accountsTable->addColumn('imip_create', Types::BOOLEAN, [ + 'default' => '0', + 'notNull' => false, + ]); + } + return $schema; + } +} diff --git a/lib/Service/IMipService.php b/lib/Service/IMipService.php index 2b976afea4..908bed87cf 100644 --- a/lib/Service/IMipService.php +++ b/lib/Service/IMipService.php @@ -115,8 +115,9 @@ public function process(): void { continue; } - $principalUri = 'principals/users/' . $account->getUserId(); + $userId = $account->getUserId(); $recipient = $account->getEmail(); + $imipCreate = $account->getImipCreate(); foreach ($filteredMessages as $message) { /** @var IMAPMessage $imapMessage */ @@ -138,20 +139,17 @@ public function process(): void { try { // an IMAP message could contain more than one iMIP object foreach ($imapMessage->scheduling as $schedulingInfo) { - if ($schedulingInfo['method'] === 'REQUEST') { - $processed = $this->calendarManager->handleIMipRequest($principalUri, $sender, $recipient, $schedulingInfo['contents']); - $message->setImipProcessed($processed); - $message->setImipError(!$processed); - } elseif ($schedulingInfo['method'] === 'REPLY') { - $processed = $this->calendarManager->handleIMipReply($principalUri, $sender, $recipient, $schedulingInfo['contents']); - $message->setImipProcessed($processed); - $message->setImipError(!$processed); - } elseif ($schedulingInfo['method'] === 'CANCEL') { - $replyTo = $imapMessage->getReplyTo()->first()?->getEmail(); - $processed = $this->calendarManager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $schedulingInfo['contents']); - $message->setImipProcessed($processed); - $message->setImipError(!$processed); - } + $processed = $this->calendarManager->handleIMip( + $userId, + $schedulingInfo['contents'], + [ + 'recipient' => $recipient, + 'absent' => $imipCreate ? 'create' : 'ignore', + 'absentCreateStatus' => 'tentative', + ], + ); + $message->setImipProcessed($processed); + $message->setImipError(!$processed); } } catch (Throwable $e) { $this->logger->error('iMIP message processing failed', [ diff --git a/package-lock.json b/package-lock.json index 213926b24a..23d45e9b35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nextcloud-mail", - "version": "5.7.0-alpha.1", + "version": "5.7.0-alpha.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nextcloud-mail", - "version": "5.7.0-alpha.1", + "version": "5.7.0-alpha.3", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 56a6a9e50f..7d03791d92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nextcloud-mail", - "version": "5.7.0-alpha.1", + "version": "5.7.0-alpha.3", "private": true, "description": "Nextcloud Mail", "license": "AGPL-3.0-only", diff --git a/src/components/AccountSettings.vue b/src/components/AccountSettings.vue index 0764872b25..50dc8e41fc 100644 --- a/src/components/AccountSettings.vue +++ b/src/components/AccountSettings.vue @@ -62,6 +62,12 @@ + + + + + + + + + diff --git a/tests/Unit/Service/IMipServiceTest.php b/tests/Unit/Service/IMipServiceTest.php index 0b2bd7a265..f7c62b8df2 100644 --- a/tests/Unit/Service/IMipServiceTest.php +++ b/tests/Unit/Service/IMipServiceTest.php @@ -36,6 +36,7 @@ class IMipServiceTest extends TestCase { /** @var AccountService|MockObject */ private $accountService; + private IManager $calendarManager; /** @var MailManager|MockObject */ @@ -77,9 +78,7 @@ public function testNoSchedulingInformation(): void { $this->accountService->expects(self::never()) ->method('findById'); $this->calendarManager->expects(self::never()) - ->method('handleIMipReply'); - $this->calendarManager->expects(self::never()) - ->method('handleIMipCancel'); + ->method('handleIMip'); $this->messageMapper->expects(self::never()) ->method('updateImipData'); @@ -113,9 +112,7 @@ public function testIsSpecialUse(): void { $this->messageMapper->expects(self::once()) ->method('updateImipData'); $this->calendarManager->expects(self::never()) - ->method('handleIMipReply'); - $this->calendarManager->expects(self::never()) - ->method('handleIMipCancel'); + ->method('handleIMip'); $this->service->process(); } @@ -146,9 +143,7 @@ public function testIsArchive(): void { $this->messageMapper->expects(self::once()) ->method('updateImipData'); $this->calendarManager->expects(self::never()) - ->method('handleIMipReply'); - $this->calendarManager->expects(self::never()) - ->method('handleIMipCancel'); + ->method('handleIMip'); $this->service->process(); } @@ -184,9 +179,7 @@ public function testNoSchedulingInfo(): void { ->with($account, $mailbox, [$message->getUid()]) ->willReturn([$imapMessage]); $this->calendarManager->expects(self::never()) - ->method('handleIMipReply'); - $this->calendarManager->expects(self::never()) - ->method('handleIMipCancel'); + ->method('handleIMip'); $this->messageMapper->expects(self::once()) ->method('updateImipData') ->with($message); @@ -226,16 +219,14 @@ public function testImapConnectionServiceException(): void { $this->logger->expects(self::once()) ->method('error'); $this->calendarManager->expects(self::never()) - ->method('handleIMipReply'); - $this->calendarManager->expects(self::never()) - ->method('handleIMipCancel'); + ->method('handleIMip'); $this->messageMapper->expects(self::never()) ->method('updateImipData'); $this->service->process(); } - public function testIsRequest(): void { + public function testHandleImipWithImipCreateDisabled(): void { $message = new Message(); $message->setImipMessage(true); $message->setUid(1); @@ -245,8 +236,9 @@ public function testIsRequest(): void { $mailbox->setAccountId(200); $mailAccount = new MailAccount(); $mailAccount->setId(200); - $mailAccount->setEmail('vincent@stardew-valley.edu'); - $mailAccount->setUserId('vincent'); + $mailAccount->setEmail('user1@example.com'); + $mailAccount->setUserId('user1'); + $mailAccount->setImipCreate(false); $account = new Account($mailAccount); $imapMessage = $this->createMock(IMAPMessage::class); $imapMessage->scheduling[] = ['method' => 'REQUEST', 'contents' => 'VCALENDAR']; @@ -281,18 +273,86 @@ public function testIsRequest(): void { $this->logger->expects(self::never()) ->method('info'); $this->calendarManager->expects(self::once()) - ->method('handleIMipRequest') - ->with('principals/users/vincent', - 'pam@stardew-bus-service.com', - $account->getEmail(), - $imapMessage->scheduling[0]['contents']); + ->method('handleIMip') + ->with( + 'user1', + $imapMessage->scheduling[0]['contents'], + [ + 'recipient' => $account->getEmail(), + 'absent' => 'ignore', + 'absentCreateStatus' => 'tentative', + ] + ); + $this->messageMapper->expects(self::once()) + ->method('updateImipData'); + + $this->service->process(); + } + + public function testHandleImipWithImipCreateEnabled(): void { + $message = new Message(); + $message->setImipMessage(true); + $message->setUid(1); + $message->setMailboxId(100); + $mailbox = new Mailbox(); + $mailbox->setId(100); + $mailbox->setAccountId(200); + $mailAccount = new MailAccount(); + $mailAccount->setId(200); + $mailAccount->setEmail('user1@example.com'); + $mailAccount->setUserId('user1'); + $mailAccount->setImipCreate(true); + $account = new Account($mailAccount); + $imapMessage = $this->createMock(IMAPMessage::class); + $imapMessage->scheduling[] = ['method' => 'REQUEST', 'contents' => 'VCALENDAR']; + $addressList = $this->createMock(AddressList::class); + $address = $this->createMock(Address::class); + + $this->messageMapper->expects(self::once()) + ->method('findIMipMessagesAscending') + ->willReturn([$message]); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->willReturn($mailbox); + $this->accountService->expects(self::once()) + ->method('findById') + ->willReturn($account); + $this->mailManager->expects(self::once()) + ->method('getImapMessagesForScheduleProcessing') + ->with($account, $mailbox, [$message->getUid()]) + ->willReturn([$imapMessage]); + $imapMessage->expects(self::once()) + ->method('getUid') + ->willReturn(1); + $imapMessage->expects(self::once()) + ->method('getFrom') + ->willReturn($addressList); + $addressList->expects(self::once()) + ->method('first') + ->willReturn($address); + $address->expects(self::once()) + ->method('getEmail') + ->willReturn('pam@stardew-bus-service.com'); + $this->logger->expects(self::never()) + ->method('info'); + $this->calendarManager->expects(self::once()) + ->method('handleIMip') + ->with( + 'user1', + $imapMessage->scheduling[0]['contents'], + [ + 'recipient' => $account->getEmail(), + 'absent' => 'create', + 'absentCreateStatus' => 'tentative', + ] + ); $this->messageMapper->expects(self::once()) ->method('updateImipData'); $this->service->process(); } - public function testIsReply(): void { + public function testHandleImipReturnsTrue(): void { $message = new Message(); $message->setImipMessage(true); $message->setUid(1); @@ -302,8 +362,9 @@ public function testIsReply(): void { $mailbox->setAccountId(200); $mailAccount = new MailAccount(); $mailAccount->setId(200); - $mailAccount->setEmail('vincent@stardew-valley.edu'); - $mailAccount->setUserId('vincent'); + $mailAccount->setEmail('user1@example.com'); + $mailAccount->setUserId('user1'); + $mailAccount->setImipCreate(false); $account = new Account($mailAccount); $imapMessage = $this->createMock(IMAPMessage::class); $imapMessage->scheduling[] = ['method' => 'REPLY', 'contents' => 'VCARD']; @@ -337,22 +398,28 @@ public function testIsReply(): void { $address->expects(self::once()) ->method('getEmail') ->willReturn('pam@stardew-bus-service.com'); - $imapMessage->expects(self::never()) - ->method('getInReplyTo') - ->willReturn($addressList); $this->calendarManager->expects(self::once()) - ->method('handleIMipReply') - ->with('principals/users/vincent', - 'pam@stardew-bus-service.com', - $account->getEmail(), - $imapMessage->scheduling[0]['contents']); + ->method('handleIMip') + ->with( + 'user1', + $imapMessage->scheduling[0]['contents'], + [ + 'recipient' => $account->getEmail(), + 'absent' => 'ignore', + 'absentCreateStatus' => 'tentative', + ] + ) + ->willReturn(true); $this->messageMapper->expects(self::once()) - ->method('updateImipData'); + ->method('updateImipData') + ->with(self::callback(function (Message $msg) { + return $msg->isImipProcessed() === true && $msg->isImipError() === false; + })); $this->service->process(); } - public function testIsCancel(): void { + public function testHandleImipReturnsFalse(): void { $message = new Message(); $message->setImipMessage(true); $message->setUid(1); @@ -362,11 +429,12 @@ public function testIsCancel(): void { $mailbox->setAccountId(200); $mailAccount = new MailAccount(); $mailAccount->setId(200); - $mailAccount->setEmail('vincent@stardew-valley.edu'); - $mailAccount->setUserId('vincent'); + $mailAccount->setEmail('user1@example.com'); + $mailAccount->setUserId('user1'); + $mailAccount->setImipCreate(false); $account = new Account($mailAccount); $imapMessage = $this->createMock(IMAPMessage::class); - $imapMessage->scheduling[] = ['method' => 'CANCEL', 'contents' => 'VCARD']; + $imapMessage->scheduling[] = ['method' => 'REPLY', 'contents' => 'VCARD']; $addressList = $this->createMock(AddressList::class); $address = $this->createMock(Address::class); @@ -397,24 +465,92 @@ public function testIsCancel(): void { $address->expects(self::once()) ->method('getEmail') ->willReturn('pam@stardew-bus-service.com'); - $imapMessage->expects(self::once()) - ->method('getReplyTo') - ->willReturn(new AddressList([])); $this->calendarManager->expects(self::once()) - ->method('handleIMipCancel') - ->with('principals/users/vincent', - 'pam@stardew-bus-service.com', - null, - $account->getEmail(), - $imapMessage->scheduling[0]['contents'] - ); + ->method('handleIMip') + ->with( + 'user1', + $imapMessage->scheduling[0]['contents'], + [ + 'recipient' => $account->getEmail(), + 'absent' => 'ignore', + 'absentCreateStatus' => 'tentative', + ] + ) + ->willReturn(false); + $this->messageMapper->expects(self::once()) + ->method('updateImipData') + ->with(self::callback(function (Message $msg) { + return $msg->isImipProcessed() === false && $msg->isImipError() === true; + })); + + $this->service->process(); + } + + public function testHandleImipWithMultipleSchedulingItems(): void { + $message = new Message(); + $message->setImipMessage(true); + $message->setUid(1); + $message->setMailboxId(100); + $mailbox = new Mailbox(); + $mailbox->setId(100); + $mailbox->setAccountId(200); + $mailAccount = new MailAccount(); + $mailAccount->setId(200); + $mailAccount->setEmail('user1@example.com'); + $mailAccount->setUserId('user1'); + $mailAccount->setImipCreate(true); + $account = new Account($mailAccount); + $imapMessage = $this->createMock(IMAPMessage::class); + $imapMessage->scheduling = [ + ['method' => 'REQUEST', 'contents' => 'VCALENDAR1'], + ['method' => 'REQUEST', 'contents' => 'VCALENDAR2'], + ]; + $addressList = $this->createMock(AddressList::class); + $address = $this->createMock(Address::class); + + $this->messageMapper->expects(self::once()) + ->method('findIMipMessagesAscending') + ->willReturn([$message]); + $this->mailboxMapper->expects(self::once()) + ->method('findById') + ->willReturn($mailbox); + $this->accountService->expects(self::once()) + ->method('findById') + ->willReturn($account); + $this->mailManager->expects(self::once()) + ->method('getImapMessagesForScheduleProcessing') + ->with($account, $mailbox, [$message->getUid()]) + ->willReturn([$imapMessage]); + $imapMessage->expects(self::once()) + ->method('getUid') + ->willReturn(1); + $this->logger->expects(self::never()) + ->method('info'); + $imapMessage->expects(self::once()) + ->method('getFrom') + ->willReturn($addressList); + $addressList->expects(self::once()) + ->method('first') + ->willReturn($address); + $address->expects(self::once()) + ->method('getEmail') + ->willReturn('pam@stardew-bus-service.com'); + $this->calendarManager->expects(self::exactly(2)) + ->method('handleIMip') + ->willReturnCallback(function ($userId, $contents, $options) use ($account) { + $this->assertEquals('user1', $userId); + $this->assertEquals($account->getEmail(), $options['recipient']); + $this->assertEquals('create', $options['absent']); + $this->assertEquals('tentative', $options['absentCreateStatus']); + return true; + }); $this->messageMapper->expects(self::once()) ->method('updateImipData'); $this->service->process(); } - public function testHandleImipRequestThrowsException(): void { + public function testHandleImipThrowsException(): void { $message = new Message(); $message->setImipMessage(true); $message->setUid(1); @@ -424,8 +560,9 @@ public function testHandleImipRequestThrowsException(): void { $mailbox->setAccountId(200); $mailAccount = new MailAccount(); $mailAccount->setId(200); - $mailAccount->setEmail('vincent@stardew-valley.edu'); - $mailAccount->setUserId('vincent'); + $mailAccount->setEmail('user1@example.com'); + $mailAccount->setUserId('user1'); + $mailAccount->setImipCreate(false); $account = new Account($mailAccount); $imapMessage = $this->createMock(IMAPMessage::class); $imapMessage->scheduling[] = ['method' => 'REQUEST', 'contents' => 'VCALENDAR']; @@ -458,7 +595,7 @@ public function testHandleImipRequestThrowsException(): void { ->method('getEmail') ->willReturn('pam@stardew-bus-service.com'); $this->calendarManager->expects(self::once()) - ->method('handleIMipRequest') + ->method('handleIMip') ->willThrowException(new \Exception('Calendar error')); $this->logger->expects(self::once()) ->method('error')