From 3cbdf1f302aa136db72feda1e32bbdb76f3622ee Mon Sep 17 00:00:00 2001 From: Richard Steinmetz Date: Mon, 20 Nov 2023 22:50:16 +0100 Subject: [PATCH] feat: apply personal out-of-office data to the auto responder Signed-off-by: Richard Steinmetz Signed-off-by: Christoph Wurst --- appinfo/info.xml | 2 +- appinfo/routes.php | 16 +- lib/AppInfo/Application.php | 10 + lib/Controller/OutOfOfficeController.php | 178 ++++++++++++ lib/Controller/PageController.php | 22 +- lib/Controller/SieveController.php | 55 +--- lib/Db/MailAccount.php | 18 ++ lib/Exception/OutOfOfficeParserException.php | 32 +++ lib/Listener/OutOfOfficeListener.php | 81 ++++++ .../Version3500Date20231114180656.php | 56 ++++ lib/Service/OutOfOffice/OutOfOfficeParser.php | 201 ++++++++++++++ .../OutOfOffice/OutOfOfficeParserResult.php | 60 ++++ lib/Service/OutOfOffice/OutOfOfficeState.php | 126 +++++++++ lib/Service/OutOfOfficeService.php | 114 ++++++++ lib/Service/SieveService.php | 95 +++++++ lib/Sieve/NamedSieveScript.php | 43 +++ package-lock.json | 4 +- package.json | 2 +- psalm.xml | 3 + src/components/OutOfOfficeForm.vue | 255 +++++++++-------- src/service/OutOfOfficeService.js | 66 +++++ src/tests/unit/util/outOfOffice.spec.js | 195 ------------- src/util/outOfOffice.js | 213 --------------- tests/Integration/Db/MailAccountTest.php | 7 +- .../Controller/OutOfOfficeControllerTest.php | 205 ++++++++++++++ tests/Unit/Controller/PageControllerTest.php | 6 + tests/Unit/Controller/SieveControllerTest.php | 55 +--- .../OutOfOffice/OutOfOfficeParserTest.php | 189 +++++++++++++ tests/Unit/Service/SieveServiceTest.php | 258 ++++++++++++++++++ .../data/sieve-vacation-cleaned.txt | 0 .../data/sieve-vacation-off.txt | 0 .../data/sieve-vacation-on-no-end-date.txt | 0 ...ieve-vacation-on-special-chars-message.txt | 0 ...ieve-vacation-on-special-chars-subject.txt | 0 .../sieve-vacation-on-subject-placeholder.txt | 0 .../data/sieve-vacation-on.txt | 0 tests/psalm-baseline.xml | 16 ++ 37 files changed, 1968 insertions(+), 615 deletions(-) create mode 100644 lib/Controller/OutOfOfficeController.php create mode 100644 lib/Exception/OutOfOfficeParserException.php create mode 100644 lib/Listener/OutOfOfficeListener.php create mode 100644 lib/Migration/Version3500Date20231114180656.php create mode 100644 lib/Service/OutOfOffice/OutOfOfficeParser.php create mode 100644 lib/Service/OutOfOffice/OutOfOfficeParserResult.php create mode 100644 lib/Service/OutOfOffice/OutOfOfficeState.php create mode 100644 lib/Service/OutOfOfficeService.php create mode 100644 lib/Service/SieveService.php create mode 100644 lib/Sieve/NamedSieveScript.php create mode 100644 src/service/OutOfOfficeService.js delete mode 100644 src/tests/unit/util/outOfOffice.spec.js delete mode 100644 src/util/outOfOffice.js create mode 100644 tests/Unit/Controller/OutOfOfficeControllerTest.php create mode 100644 tests/Unit/Service/OutOfOffice/OutOfOfficeParserTest.php create mode 100644 tests/Unit/Service/SieveServiceTest.php rename {src/tests => tests}/data/sieve-vacation-cleaned.txt (100%) rename {src/tests => tests}/data/sieve-vacation-off.txt (100%) rename {src/tests => tests}/data/sieve-vacation-on-no-end-date.txt (100%) rename {src/tests => tests}/data/sieve-vacation-on-special-chars-message.txt (100%) rename {src/tests => tests}/data/sieve-vacation-on-special-chars-subject.txt (100%) rename {src/tests => tests}/data/sieve-vacation-on-subject-placeholder.txt (100%) rename {src/tests => tests}/data/sieve-vacation-on.txt (100%) diff --git a/appinfo/info.xml b/appinfo/info.xml index 084081a1bc..9bbb462eae 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -29,7 +29,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/). ]]> - 3.6.0-alpha.1 + 3.6.0-alpha.2 agpl Christoph Wurst Nextcloud Groupware Team diff --git a/appinfo/routes.php b/appinfo/routes.php index 688279f9bf..c2879df576 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -450,7 +450,21 @@ 'url' => '/api/drafts/move/{id}', 'verb' => 'POST', ], - + [ + 'name' => 'outOfOffice#getState', + 'url' => '/api/out-of-office/{accountId}', + 'verb' => 'GET', + ], + [ + 'name' => 'outOfOffice#update', + 'url' => '/api/out-of-office/{accountId}', + 'verb' => 'POST', + ], + [ + 'name' => 'outOfOffice#followSystem', + 'url' => '/api/out-of-office/{accountId}/follow-system', + 'verb' => 'POST', + ], ], 'resources' => [ 'accounts' => ['url' => '/api/accounts'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index dd71aa309e..d23e95fb6e 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -66,6 +66,7 @@ use OCA\Mail\Listener\MoveJunkListener; use OCA\Mail\Listener\NewMessageClassificationListener; use OCA\Mail\Listener\OauthTokenRefreshListener; +use OCA\Mail\Listener\OutOfOfficeListener; use OCA\Mail\Listener\SaveSentMessageListener; use OCA\Mail\Listener\SpamReportListener; use OCA\Mail\Listener\UserDeletedListener; @@ -88,6 +89,8 @@ use OCP\Dashboard\IAPIWidgetV2; use OCP\IServerContainer; use OCP\Search\IFilteringProvider; +use OCP\User\Events\OutOfOfficeEndedEvent; +use OCP\User\Events\OutOfOfficeStartedEvent; use OCP\User\Events\UserDeletedEvent; use OCP\Util; use Psr\Container\ContainerInterface; @@ -142,6 +145,13 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(SynchronizationEvent::class, AccountSynchronizedThreadUpdaterListener::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); + // TODO: drop condition if nextcloud < 28 is not supported anymore + if (class_exists(OutOfOfficeStartedEvent::class) + && class_exists(OutOfOfficeEndedEvent::class)) { + $context->registerEventListener(OutOfOfficeStartedEvent::class, OutOfOfficeListener::class); + $context->registerEventListener(OutOfOfficeEndedEvent::class, OutOfOfficeListener::class); + } + $context->registerMiddleWare(ErrorMiddleware::class); $context->registerMiddleWare(ProvisioningMiddleware::class); diff --git a/lib/Controller/OutOfOfficeController.php b/lib/Controller/OutOfOfficeController.php new file mode 100644 index 0000000000..c6ceb31870 --- /dev/null +++ b/lib/Controller/OutOfOfficeController.php @@ -0,0 +1,178 @@ + + * + * @author Richard Steinmetz + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Controller; + +use DateTimeImmutable; +use OCA\Mail\AppInfo\Application; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Http\JsonResponse; +use OCA\Mail\Http\TrapError; +use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\OutOfOffice\OutOfOfficeState; +use OCA\Mail\Service\OutOfOfficeService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IRequest; +use OCP\IUserSession; +use OCP\User\IAvailabilityCoordinator; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; + +class OutOfOfficeController extends Controller { + private ?IAvailabilityCoordinator $availabilityCoordinator; + + public function __construct( + IRequest $request, + ContainerInterface $container, + private IUserSession $userSession, + private AccountService $accountService, + private OutOfOfficeService $outOfOfficeService, + private ITimeFactory $timeFactory, + ) { + parent::__construct(Application::APP_ID, $request); + + try { + $this->availabilityCoordinator = $container->get(IAvailabilityCoordinator::class); + } catch (ContainerExceptionInterface) { + $this->availabilityCoordinator = null; + } + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + #[TrapError] + public function getState(int $accountId): JsonResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return JsonResponse::fail([], Http::STATUS_FORBIDDEN); + } + + $account = $this->accountService->findById($accountId); + if ($account->getUserId() !== $user->getUID()) { + return JsonResponse::fail([], Http::STATUS_NOT_FOUND); + } + + $state = $this->outOfOfficeService->parseState($account->getMailAccount()); + return JsonResponse::success($state); + } + + /** + * @NoAdminRequired + */ + #[TrapError] + public function followSystem(int $accountId) { + if ($this->availabilityCoordinator === null) { + return JsonResponse::fail([], Http::STATUS_NOT_IMPLEMENTED); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return JsonResponse::fail([], Http::STATUS_FORBIDDEN); + } + + $account = $this->accountService->findById($accountId); + if ($account->getUserId() !== $user->getUID()) { + return JsonResponse::fail([], Http::STATUS_NOT_FOUND); + } + + $mailAccount = $account->getMailAccount(); + if (!$mailAccount->getOutOfOfficeFollowsSystem()) { + $mailAccount->setOutOfOfficeFollowsSystem(true); + $this->accountService->update($mailAccount); + } + + $state = null; + $now = $this->timeFactory->getTime(); + $currentOutOfOfficeData = $this->availabilityCoordinator->getCurrentOutOfOfficeData($user); + if ($currentOutOfOfficeData !== null + && $currentOutOfOfficeData->getStartDate() <= $now + && $currentOutOfOfficeData->getEndDate() > $now) { + // In the middle of a running absence => enable auto responder + $state = new OutOfOfficeState( + true, + new DateTimeImmutable("@" . $currentOutOfOfficeData->getStartDate()), + new DateTimeImmutable("@" . $currentOutOfOfficeData->getEndDate()), + $currentOutOfOfficeData->getShortMessage(), + $currentOutOfOfficeData->getMessage(), + ); + $this->outOfOfficeService->update($mailAccount, $state); + } else { + // Absence has not yet started or has already ended => disable auto responder + $this->outOfOfficeService->disable($mailAccount); + } + + return JsonResponse::success($state); + } + + /** + * @NoAdminRequired + */ + #[TrapError] + public function update( + int $accountId, + bool $enabled, + ?string $start, + ?string $end, + string $subject, + string $message, + ): JsonResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return JsonResponse::fail([], Http::STATUS_FORBIDDEN); + } + + $account = $this->accountService->findById($accountId); + if ($account->getUserId() !== $user->getUID()) { + return JsonResponse::fail([], Http::STATUS_NOT_FOUND); + } + + if ($enabled && $start === null) { + throw new ServiceException("Missing start date"); + } + + $mailAccount = $account->getMailAccount(); + if ($mailAccount->getOutOfOfficeFollowsSystem()) { + $mailAccount->setOutOfOfficeFollowsSystem(false); + $this->accountService->update($mailAccount); + } + + $state = new OutOfOfficeState( + $enabled, + $start ? new DateTimeImmutable($start) : null, + $end ? new DateTimeImmutable($end) : null, + $subject, + $message, + ); + $this->outOfOfficeService->update($mailAccount, $state); + + $newState = $this->outOfOfficeService->parseState($mailAccount); + return JsonResponse::success($newState); + } +} diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 1764f6b229..743dd9c03f 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -53,6 +53,9 @@ use OCP\IURLGenerator; use OCP\IUserManager; use OCP\IUserSession; +use OCP\User\IAvailabilityCoordinator; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Throwable; use function class_exists; @@ -77,6 +80,7 @@ class PageController extends Controller { private SmimeService $smimeService; private AiIntegrationsService $aiIntegrationsService; private IUserManager $userManager; + private ?IAvailabilityCoordinator $availabilityCoordinator; public function __construct(string $appName, IRequest $request, @@ -96,7 +100,8 @@ public function __construct(string $appName, ICredentialStore $credentialStore, SmimeService $smimeService, AiIntegrationsService $aiIntegrationsService, - IUserManager $userManager, ) { + IUserManager $userManager, + ContainerInterface $container) { parent::__construct($appName, $request); $this->urlGenerator = $urlGenerator; @@ -116,6 +121,12 @@ public function __construct(string $appName, $this->smimeService = $smimeService; $this->aiIntegrationsService = $aiIntegrationsService; $this->userManager = $userManager; + + try { + $this->availabilityCoordinator = $container->get(IAvailabilityCoordinator::class); + } catch (ContainerExceptionInterface $e) { + $this->availabilityCoordinator = null; + } } /** @@ -174,7 +185,7 @@ public function index(): TemplateResponse { 'sort-order', $this->preferences->getPreference($this->currentUserId, 'sort-order', 'newest') ); - + try { $password = $this->credentialStore->getLoginCredentials()->getPassword(); $passwordIsUnavailable = $password === null || $password === ''; @@ -277,6 +288,13 @@ function (SmimeCertificate $certificate) { ), ); + if ($this->availabilityCoordinator !== null) { + $this->initialStateService->provideInitialState( + 'enable-system-out-of-office', + $this->availabilityCoordinator->isEnabled(), + ); + } + $csp = new ContentSecurityPolicy(); $csp->addAllowedFrameDomain('\'self\''); $response->setContentSecurityPolicy($csp); diff --git a/lib/Controller/SieveController.php b/lib/Controller/SieveController.php index 2bfb68e6ea..54ca93e476 100644 --- a/lib/Controller/SieveController.php +++ b/lib/Controller/SieveController.php @@ -4,6 +4,7 @@ /** * @author Daniel Kesselberg + * @author Richard Steinmetz * * Mail * @@ -30,7 +31,7 @@ use OCA\Mail\Exception\CouldNotConnectException; use OCA\Mail\Http\JsonResponse as MailJsonResponse; use OCA\Mail\Http\TrapError; -use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\SieveService; use OCA\Mail\Sieve\SieveClientFactory; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; @@ -42,7 +43,6 @@ use Psr\Log\LoggerInterface; class SieveController extends Controller { - private AccountService $accountService; private MailAccountMapper $mailAccountMapper; private SieveClientFactory $sieveClientFactory; private string $currentUserId; @@ -52,16 +52,15 @@ class SieveController extends Controller { public function __construct(IRequest $request, string $UserId, - AccountService $accountService, MailAccountMapper $mailAccountMapper, SieveClientFactory $sieveClientFactory, ICrypto $crypto, IRemoteHostValidator $hostValidator, - LoggerInterface $logger + LoggerInterface $logger, + private SieveService $sieveService, ) { parent::__construct(Application::APP_ID, $request); $this->currentUserId = $UserId; - $this->accountService = $accountService; $this->mailAccountMapper = $mailAccountMapper; $this->sieveClientFactory = $sieveClientFactory; $this->crypto = $crypto; @@ -78,21 +77,14 @@ public function __construct(IRequest $request, * * @throws CouldNotConnectException * @throws ClientException + * @throws ManagesieveException */ #[TrapError] public function getActiveScript(int $id): JSONResponse { - $sieve = $this->getClient($id); - - $scriptName = $sieve->getActive(); - if ($scriptName === null) { - $script = ''; - } else { - $script = $sieve->getScript($scriptName); - } - + $activeScript = $this->sieveService->getActiveScript($this->currentUserId, $id); return new JSONResponse([ - 'scriptName' => $scriptName, - 'script' => $script, + 'scriptName' => $activeScript->getName(), + 'script' => $activeScript->getScript(), ]); } @@ -106,16 +98,11 @@ public function getActiveScript(int $id): JSONResponse { * * @throws ClientException * @throws CouldNotConnectException - * @throws ManagesieveException */ #[TrapError] public function updateActiveScript(int $id, string $script): JSONResponse { - $sieve = $this->getClient($id); - - $scriptName = $sieve->getActive() ?? 'nextcloud'; - try { - $sieve->installScript($scriptName, $script, true); + $this->sieveService->updateActiveScript($this->currentUserId, $id, $script); } catch (ManagesieveException $e) { $this->logger->error('Installing sieve script failed: ' . $e->getMessage(), ['app' => 'mail', 'exception' => $e]); return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_UNPROCESSABLE_ENTITY); @@ -200,28 +187,4 @@ public function updateAccount(int $id, $this->mailAccountMapper->save($mailAccount); return new JSONResponse(['sieveEnabled' => $mailAccount->isSieveEnabled()]); } - - /** - * @param int $id - * - * @return \Horde\ManageSieve - * - * @throws ClientException - * @throws CouldNotConnectException - */ - protected function getClient(int $id): \Horde\ManageSieve { - $account = $this->accountService->find($this->currentUserId, $id); - - if (!$account->getMailAccount()->isSieveEnabled()) { - throw new ClientException('ManageSieve is disabled.'); - } - - try { - $sieve = $this->sieveClientFactory->getClient($account); - } catch (ManagesieveException $e) { - throw new CouldNotConnectException($e, 'ManageSieve', $account->getMailAccount()->getSieveHost(), $account->getMailAccount()->getSievePort()); - } - - return $sieve; - } } diff --git a/lib/Db/MailAccount.php b/lib/Db/MailAccount.php index 3744b1414a..3fd86b4e14 100644 --- a/lib/Db/MailAccount.php +++ b/lib/Db/MailAccount.php @@ -116,6 +116,8 @@ * @method void setJunkMailboxId(?int $id) * @method bool getSearchBody() * @method void setSearchBody(bool $searchBody) + * @method bool|null getOooFollowsSystem() + * @method void setOooFollowsSystem(bool $oooFollowsSystem) */ class MailAccount extends Entity { public const SIGNATURE_MODE_PLAIN = 0; @@ -195,6 +197,9 @@ class MailAccount extends Entity { /** @var bool */ protected $searchBody = false; + /** @var bool|null */ + protected $oooFollowsSystem; + /** * @param array $params */ @@ -249,6 +254,9 @@ public function __construct(array $params = []) { if (isset($params['trashRetentionDays'])) { $this->setTrashRetentionDays($params['trashRetentionDays']); } + if (isset($params['outOfOfficeFollowsSystem'])) { + $this->setOutOfOfficeFollowsSystem($params['outOfOfficeFollowsSystem']); + } $this->addType('inboundPort', 'integer'); $this->addType('outboundPort', 'integer'); @@ -271,6 +279,15 @@ public function __construct(array $params = []) { $this->addType('trashRetentionDays', 'integer'); $this->addType('junkMailboxId', 'integer'); $this->addType('searchBody', 'boolean'); + $this->addType('oooFollowsSystem', 'boolean'); + } + + public function getOutOfOfficeFollowsSystem(): bool { + return $this->getOooFollowsSystem() === true; + } + + public function setOutOfOfficeFollowsSystem(bool $outOfOfficeFollowsSystem): void { + $this->setOooFollowsSystem($outOfOfficeFollowsSystem); } /** @@ -305,6 +322,7 @@ public function toJson() { 'trashRetentionDays' => $this->getTrashRetentionDays(), 'junkMailboxId' => $this->getJunkMailboxId(), 'searchBody' => $this->getSearchBody(), + 'outOfOfficeFollowsSystem' => $this->getOutOfOfficeFollowsSystem(), ]; if (!is_null($this->getOutboundHost())) { diff --git a/lib/Exception/OutOfOfficeParserException.php b/lib/Exception/OutOfOfficeParserException.php new file mode 100644 index 0000000000..08eceea2b6 --- /dev/null +++ b/lib/Exception/OutOfOfficeParserException.php @@ -0,0 +1,32 @@ + + * + * @author Richard Steinmetz + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Exception; + +use Exception; + +class OutOfOfficeParserException extends Exception { +} diff --git a/lib/Listener/OutOfOfficeListener.php b/lib/Listener/OutOfOfficeListener.php new file mode 100644 index 0000000000..b5c62c361b --- /dev/null +++ b/lib/Listener/OutOfOfficeListener.php @@ -0,0 +1,81 @@ + + * + * @author Richard Steinmetz + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Listener; + +use DateTimeImmutable; +use Exception; +use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\OutOfOffice\OutOfOfficeState; +use OCA\Mail\Service\OutOfOfficeService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\User\Events\OutOfOfficeEndedEvent; +use OCP\User\Events\OutOfOfficeStartedEvent; +use Psr\Log\LoggerInterface; + +/** + * @template-implements IEventListener + */ +class OutOfOfficeListener implements IEventListener { + public function __construct( + private AccountService $accountService, + private OutOfOfficeService $outOfOfficeService, + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof OutOfOfficeStartedEvent) && !($event instanceof OutOfOfficeEndedEvent)) { + return; + } + + $eventData = $event->getData(); + $accounts = $this->accountService->findByUserId($event->getData()->getUser()->getUID()); + foreach ($accounts as $account) { + if (!$account->getMailAccount()->getOutOfOfficeFollowsSystem()) { + continue; + } + + $state = new OutOfOfficeState( + ($event instanceof OutOfOfficeStartedEvent), + new DateTimeImmutable('@' . $eventData->getStartDate()), + new DateTimeImmutable('@' . $eventData->getEndDate()), + $eventData->getShortMessage(), + $eventData->getMessage(), + ); + try { + $this->outOfOfficeService->update($account->getMailAccount(), $state); + } catch (Exception $e) { + $this->logger->error('Failed to apply out-of-office sieve script: ' . $e->getMessage(), [ + 'exception' => $e, + 'userId' => $account->getUserId(), + 'accountId' => $account->getId(), + ]); + } + } + } +} diff --git a/lib/Migration/Version3500Date20231114180656.php b/lib/Migration/Version3500Date20231114180656.php new file mode 100644 index 0000000000..f01b6c39f2 --- /dev/null +++ b/lib/Migration/Version3500Date20231114180656.php @@ -0,0 +1,56 @@ + + * + * @author Richard Steinmetz + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version3500Date20231114180656 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $accountsTable = $schema->getTable('mail_accounts'); + if (!$accountsTable->hasColumn('ooo_follows_system')) { + $accountsTable->addColumn('ooo_follows_system', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + } + + return $schema; + } +} diff --git a/lib/Service/OutOfOffice/OutOfOfficeParser.php b/lib/Service/OutOfOffice/OutOfOfficeParser.php new file mode 100644 index 0000000000..4b98d3a00e --- /dev/null +++ b/lib/Service/OutOfOffice/OutOfOfficeParser.php @@ -0,0 +1,201 @@ + + * + * @author Richard Steinmetz + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Service\OutOfOffice; + +use DateTimeImmutable; +use JsonException; +use OCA\Mail\Exception\OutOfOfficeParserException; + +/** + * Parses and builds out-of-office states from/to sieve scripts. + */ +class OutOfOfficeParser { + private const SEPARATOR = '### Nextcloud Mail: Vacation Responder ### DON\'T EDIT ###'; + private const DATA_MARKER = '# DATA: '; + + private const STATE_COPY = 0; + private const STATE_SKIP = 1; + + /** + * @throws OutOfOfficeParserException + */ + public function parseOutOfOfficeState(string $sieveScript): OutOfOfficeParserResult { + $data = null; + $scriptOut = []; + + $state = self::STATE_COPY; + $nextState = $state; + + $lines = preg_split('/\r?\n/', $sieveScript); + foreach ($lines as $line) { + switch ($state) { + case self::STATE_COPY: + if (str_starts_with($line, self::SEPARATOR)) { + $nextState = self::STATE_SKIP; + } else { + $scriptOut[] = $line; + } + break; + case self::STATE_SKIP: + if (str_starts_with($line, self::SEPARATOR)) { + $nextState = self::STATE_COPY; + } elseif (str_starts_with($line, self::DATA_MARKER)) { + $json = substr($line, strlen(self::DATA_MARKER)); + try { + $jsonData = json_decode($json, true, 10, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new OutOfOfficeParserException( + 'Failed to parse out-of-office state json: ' . $e->getMessage(), + 0, + $e, + ); + } + $data = OutOfOfficeState::fromJson($jsonData); + } + break; + default: + throw new OutOfOfficeParserException('Reached an invalid state'); + } + $state = $nextState; + } + + return new OutOfOfficeParserResult($data, $sieveScript, implode("\n", $scriptOut)); + } + + /** + * @param string[] $allowedRecipients Respond to envelopes that are addressed to the given addresses. + * Should be the main address and aliases of the account. + * An empty array will leave the decision to the sieve implementation. + * + * @throws OutOfOfficeParserException If the given out-of-office state is missing required fields. + * @throws JSONException If the given out-of-office state can't be serialized to JSON. + */ + public function buildSieveScript( + OutOfOfficeState $state, + string $untouchedScript, + array $allowedRecipients, + ): string { + // No need to persist dates if not enabled + if (!$state->isEnabled()) { + $state->setStart(null); + $state->setEnd(null); + } + + $stateJsonString = json_encode($state, JSON_THROW_ON_ERROR); + + if (!$state->isEnabled()) { + //unset($jsonData['start'], $jsonString['end']); + return implode("\n", [ + $untouchedScript, + self::SEPARATOR, + self::DATA_MARKER . $stateJsonString, + self::SEPARATOR, + ]); + } + + if ($state->getStart() === null) { + throw new OutOfOfficeParserException('Out-of-office state is missing a start date'); + } + + $formattedStart = $this->formatDateForSieve($state->getStart()); + if ($state->getEnd() !== null) { + $formattedEnd = $this->formatDateForSieve($state->getEnd()); + $condition = "allof(currentdate :value \"ge\" \"date\" \"$formattedStart\", currentdate :value \"le\" \"date\" \"$formattedEnd\")"; + } else { + $condition = "currentdate :value \"ge\" \"date\" \"$formattedStart\""; + } + + $escapedSubject = $this->escapeStringForSieve($state->getSubject()); + $vacation = [ + 'vacation', + ':days 4', + ":subject \"$escapedSubject\"", + ]; + + if (!empty($allowedRecipients)) { + $formattedRecipients = array_map(static function (string $recipient) { + return "\"$recipient\""; + }, $allowedRecipients); + $joinedRecipients = implode(', ', $formattedRecipients); + $vacation[] = ":addresses [$joinedRecipients]"; + } + + $escapedMessage = $this->escapeStringForSieve($state->getMessage()); + $vacation[] = "\"$escapedMessage\""; + $vacationCommand = implode(' ', $vacation); + + $subjectSection = [ + 'set "subject" "";', + 'if header :matches "subject" "*" {', + "\tset \"subject\" \"\${1}\";", + '}', + ]; + + $hasSubjectPlaceholder = str_contains($state->getSubject(), '${subject}') + || str_contains($state->getMessage(), '${subject}'); + + $requireSection = [ + self::SEPARATOR, + 'require "date";', + 'require "relational";', + 'require "vacation";', + ]; + if ($hasSubjectPlaceholder) { + $requireSection[] = 'require "variables";'; + } + $requireSection[] = self::SEPARATOR; + + $vacationSection = [ + self::SEPARATOR, + self::DATA_MARKER . $stateJsonString, + ]; + if ($hasSubjectPlaceholder) { + $vacationSection = array_merge($vacationSection, $subjectSection); + } + $vacationSection = array_merge($vacationSection, [ + "if $condition {", + "\t$vacationCommand;", + '}', + self::SEPARATOR, + ]); + + return implode("\n", array_merge( + $requireSection, + [$untouchedScript], + $vacationSection, + )); + } + + private function formatDateForSieve(DateTimeImmutable $date): string { + return $date->format('Y-m-d'); + } + + private function escapeStringForSieve(string $subject): string { + $subject = preg_replace('/\\\\/', '\\\\\\\\', $subject); + return preg_replace('/"/', '\\"', $subject); + } +} diff --git a/lib/Service/OutOfOffice/OutOfOfficeParserResult.php b/lib/Service/OutOfOffice/OutOfOfficeParserResult.php new file mode 100644 index 0000000000..560e38c7fd --- /dev/null +++ b/lib/Service/OutOfOffice/OutOfOfficeParserResult.php @@ -0,0 +1,60 @@ + + * + * @author Richard Steinmetz + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Service\OutOfOffice; + +use JsonSerializable; +use ReturnTypeWillChange; + +class OutOfOfficeParserResult implements JsonSerializable { + public function __construct( + private ?OutOfOfficeState $state, + private string $sieveScript, + private string $untouchedSieveScript, + ) { + } + + public function getState(): ?OutOfOfficeState { + return $this->state; + } + + public function getSieveScript(): string { + return $this->sieveScript; + } + + public function getUntouchedSieveScript(): string { + return $this->untouchedSieveScript; + } + + #[ReturnTypeWillChange] + public function jsonSerialize() { + return [ + 'state' => $this->getState()?->jsonSerialize(), + 'script' => $this->getSieveScript(), + 'untouchedScript' => $this->getUntouchedSieveScript(), + ]; + } +} diff --git a/lib/Service/OutOfOffice/OutOfOfficeState.php b/lib/Service/OutOfOffice/OutOfOfficeState.php new file mode 100644 index 0000000000..2a774292fb --- /dev/null +++ b/lib/Service/OutOfOffice/OutOfOfficeState.php @@ -0,0 +1,126 @@ + + * + * @author Richard Steinmetz + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Service\OutOfOffice; + +use DateTimeImmutable; +use JsonSerializable; +use ReturnTypeWillChange; + +class OutOfOfficeState implements JsonSerializable { + public const DEFAULT_VERSION = 1; + + public function __construct( + private bool $enabled, + private ?DateTimeImmutable $start, + private ?DateTimeImmutable $end, + private string $subject, + private string $message, + private int $version = self::DEFAULT_VERSION, + ) { + } + + public static function fromJson(array $data): self { + return new self( + $data['enabled'], + isset($data['start']) ? new DateTimeImmutable($data['start']) : null, + isset($data['end']) ? new DateTimeImmutable($data['end']) : null, + $data['subject'], + $data['message'], + $data['version'], + ); + } + + public function getVersion(): int { + return $this->version; + } + + public function setVersion(int $version): void { + $this->version = $version; + } + + public function isEnabled(): bool { + return $this->enabled; + } + + public function setEnabled(bool $enabled): void { + $this->enabled = $enabled; + } + + public function getStart(): ?DateTimeImmutable { + return $this->start; + } + + public function setStart(?DateTimeImmutable $start): void { + $this->start = $start; + } + + public function getEnd(): ?DateTimeImmutable { + return $this->end; + } + + public function setEnd(?DateTimeImmutable $end): void { + $this->end = $end; + } + + public function getSubject(): string { + return $this->subject; + } + + public function setSubject(string $subject): void { + $this->subject = $subject; + } + + public function getMessage(): string { + return $this->message; + } + + public function setMessage(string $message): void { + $this->message = $message; + } + + #[ReturnTypeWillChange] + public function jsonSerialize() { + $json = [ + 'version' => $this->getVersion(), + 'enabled' => $this->isEnabled(), + ]; + + $start = $this->getStart(); + if ($start) { + $json['start'] = $start->format('Y-m-d'); + } + + $end = $this->getEnd(); + if ($end) { + $json['end'] = $end->format('Y-m-d'); + } + + $json['subject'] = $this->getSubject(); + $json['message'] = $this->getMessage(); + return $json; + } +} diff --git a/lib/Service/OutOfOfficeService.php b/lib/Service/OutOfOfficeService.php new file mode 100644 index 0000000000..02f0b9a452 --- /dev/null +++ b/lib/Service/OutOfOfficeService.php @@ -0,0 +1,114 @@ + + * + * @author Richard Steinmetz + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Service; + +use Horde\ManageSieve\Exception as ManageSieveException; +use JsonException; +use OCA\Mail\Db\Alias; +use OCA\Mail\Db\MailAccount; +use OCA\Mail\Exception\ClientException; +use OCA\Mail\Exception\CouldNotConnectException; +use OCA\Mail\Exception\OutOfOfficeParserException; +use OCA\Mail\Service\OutOfOffice\OutOfOfficeParser; +use OCA\Mail\Service\OutOfOffice\OutOfOfficeParserResult; +use OCA\Mail\Service\OutOfOffice\OutOfOfficeState; +use Psr\Log\LoggerInterface; + +class OutOfOfficeService { + public function __construct( + private OutOfOfficeParser $outOfOfficeParser, + private SieveService $sieveService, + private LoggerInterface $logger, + private AliasesService $aliasesService, + ) { + } + + /** + * @throws ClientException + * @throws OutOfOfficeParserException + * @throws ManageSieveException + * @throws CouldNotConnectException + */ + public function parseState(MailAccount $account): OutOfOfficeParserResult { + $script = $this->sieveService->getActiveScript($account->getUserId(), $account->getId()); + return $this->outOfOfficeParser->parseOutOfOfficeState($script->getScript()); + } + + /** + * @throws CouldNotConnectException + * @throws JsonException + * @throws ClientException + * @throws OutOfOfficeParserException + * @throws ManageSieveException + */ + public function update(MailAccount $account, OutOfOfficeState $state): void { + $script = $this->sieveService->getActiveScript($account->getUserId(), $account->getId()); + $oldState = $this->outOfOfficeParser->parseOutOfOfficeState($script->getScript()); + $newScript = $this->outOfOfficeParser->buildSieveScript( + $state, + $oldState->getUntouchedSieveScript(), + $this->buildAllowedRecipients($account), + ); + try { + $this->sieveService->updateActiveScript($account->getUserId(), $account->getId(), $newScript); + } catch (ManageSieveException $e) { + $this->logger->error('Failed to save sieve script: ' . $e->getMessage(), [ + 'exception' => $e, + 'script' => $newScript, + ]); + throw $e; + } + } + + /** + * @throws ClientException + * @throws OutOfOfficeParserException + * @throws ManageSieveException + * @throws CouldNotConnectException + * @throws JsonException + */ + public function disable(MailAccount $account): void { + $state = $this->parseState($account)->getState(); + if ($state === null || !$state->isEnabled()) { + return; + } + + $state->setEnabled(false); + $this->update($account, $state); + } + + /** + * @return string[] + */ + private function buildAllowedRecipients(MailAccount $mailAccount): array { + $aliases = $this->aliasesService->findAll($mailAccount->getId(), $mailAccount->getUserId()); + $formattedAliases = array_map(static function (Alias $alias) { + return $alias->getAlias(); + }, $aliases); + return array_merge([$mailAccount->getEmail()], $formattedAliases); + } +} diff --git a/lib/Service/SieveService.php b/lib/Service/SieveService.php new file mode 100644 index 0000000000..806dfecf52 --- /dev/null +++ b/lib/Service/SieveService.php @@ -0,0 +1,95 @@ + + * + * @author Richard Steinmetz + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Service; + +use Horde\ManageSieve\Exception as ManagesieveException; +use OCA\Mail\Exception\ClientException; +use OCA\Mail\Exception\CouldNotConnectException; +use OCA\Mail\Sieve\NamedSieveScript; +use OCA\Mail\Sieve\SieveClientFactory; + +class SieveService { + public function __construct( + private SieveClientFactory $sieveClientFactory, + private AccountService $accountService, + ) { + } + + /** + * @throws CouldNotConnectException + * @throws ClientException + * @throws ManagesieveException + */ + public function getActiveScript(string $userId, int $accountId): NamedSieveScript { + $sieve = $this->getClient($userId, $accountId); + + $scriptName = $sieve->getActive(); + if ($scriptName === null) { + $script = ''; + } else { + $script = $sieve->getScript($scriptName); + } + + // Sieve appends the script with a carriage return and line feed (\r\n) each time it's saved. + // Strip those line feeds to avoid the accumulation of unnecessary white space. + $script = rtrim($script, "\r\n"); + + return new NamedSieveScript($scriptName, $script); + } + + /** + * @throws ClientException + * @throws CouldNotConnectException + * @throws ManagesieveException + */ + public function updateActiveScript(string $userId, int $accountId, string $script): void { + $sieve = $this->getClient($userId, $accountId); + + $scriptName = $sieve->getActive() ?? 'nextcloud'; + $sieve->installScript($scriptName, $script, true); + } + + /** + * @throws ClientException + * @throws CouldNotConnectException + */ + private function getClient(string $userId, int $accountId): \Horde\ManageSieve { + $account = $this->accountService->find($userId, $accountId); + + if (!$account->getMailAccount()->isSieveEnabled()) { + throw new ClientException('ManageSieve is disabled'); + } + + try { + $sieve = $this->sieveClientFactory->getClient($account); + } catch (ManagesieveException $e) { + throw new CouldNotConnectException($e, 'ManageSieve', $account->getMailAccount()->getSieveHost(), $account->getMailAccount()->getSievePort()); + } + + return $sieve; + } +} diff --git a/lib/Sieve/NamedSieveScript.php b/lib/Sieve/NamedSieveScript.php new file mode 100644 index 0000000000..350358995b --- /dev/null +++ b/lib/Sieve/NamedSieveScript.php @@ -0,0 +1,43 @@ + + * + * @author Richard Steinmetz + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Sieve; + +class NamedSieveScript { + public function __construct( + private ?string $name, + private string $script, + ) { + } + + public function getName(): ?string { + return $this->name; + } + + public function getScript(): string { + return $this->script; + } +} diff --git a/package-lock.json b/package-lock.json index 5bb4028905..3c6a5448e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nextcloud-mail", - "version": "3.6.0-alpha1", + "version": "3.6.0-alpha2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "nextcloud-mail", - "version": "3.6.0-alpha1", + "version": "3.6.0-alpha2", "license": "agpl", "dependencies": { "@ckeditor/ckeditor5-alignment": "37.1.0", diff --git a/package.json b/package.json index 5a710b196d..96d61b6c77 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nextcloud-mail", "description": "Nextcloud Mail", - "version": "3.6.0-alpha1", + "version": "3.6.0-alpha2", "author": "Christoph Wurst ", "license": "agpl", "private": true, diff --git a/psalm.xml b/psalm.xml index 644ab5e266..4286154ec1 100644 --- a/psalm.xml +++ b/psalm.xml @@ -47,6 +47,9 @@ + + + diff --git a/src/components/OutOfOfficeForm.vue b/src/components/OutOfOfficeForm.vue index b26f103e98..2fbca730cc 100644 --- a/src/components/OutOfOfficeForm.vue +++ b/src/components/OutOfOfficeForm.vue @@ -21,8 +21,7 @@ -->