From 5bbace098b8318a97ece43000885d9ad00e37d64 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Wed, 17 Jul 2024 15:49:02 +0200 Subject: [PATCH] feat: mail filters Co-authored-by: Hamza Mahjoubi Signed-off-by: Daniel Kesselberg --- REUSE.toml | 6 + lib/Controller/MailfilterController.php | 58 +++++ lib/Exception/FilterParserException.php | 29 +++ lib/Service/AllowedRecipientsService.php | 34 +++ lib/Service/MailFilter/FilterBuilder.php | 154 +++++++++++++ lib/Service/MailFilter/FilterParser.php | 62 +++++ lib/Service/MailFilter/FilterParserResult.php | 43 ++++ lib/Service/MailFilter/FilterState.php | 35 +++ lib/Service/MailFilterService.php | 89 ++++++++ lib/Service/OutOfOffice/OutOfOfficeParser.php | 10 +- lib/Service/OutOfOfficeService.php | 16 +- src/components/AccountSettings.vue | 9 + .../mailFilter/MailFilterAction.vue | 96 ++++++++ .../mailFilter/MailFilterActionAddflag.vue | 41 ++++ .../mailFilter/MailFilterActionFileinto.vue | 49 ++++ .../mailFilter/MailFilterDeleteModal.vue | 61 +++++ .../mailFilter/MailFilterOperator.vue | 54 +++++ src/components/mailFilter/MailFilterTest.vue | 125 ++++++++++ .../mailFilter/MailFilterUpdateModal.vue | 178 +++++++++++++++ src/components/mailFilter/MailFilters.vue | 216 ++++++++++++++++++ src/service/MailFilterService.js | 20 ++ src/store/mailFilterStore.js | 60 +++++ .../Service/AllowedRecipientsServiceTest.php | 54 +++++ .../Service/MailFilter/FilterBuilderTest.php | 65 ++++++ .../Service/MailFilter/FilterParserTest.php | 77 +++++++ tests/Unit/Service/OutOfOfficeServiceTest.php | 20 +- tests/data/mail-filter/builder1.json | 24 ++ tests/data/mail-filter/builder1.sieve | 11 + tests/data/mail-filter/builder2.json | 31 +++ tests/data/mail-filter/builder2.sieve | 11 + tests/data/mail-filter/builder3.json | 55 +++++ tests/data/mail-filter/builder3.sieve | 16 ++ tests/data/mail-filter/builder4.json | 15 ++ tests/data/mail-filter/builder4.sieve | 6 + tests/data/mail-filter/builder5.json | 27 +++ tests/data/mail-filter/builder5.sieve | 12 + tests/data/mail-filter/builder6.json | 36 +++ tests/data/mail-filter/builder6.sieve | 12 + tests/data/mail-filter/parser1.sieve | 11 + tests/data/mail-filter/parser2.sieve | 11 + 40 files changed, 1908 insertions(+), 31 deletions(-) create mode 100644 lib/Controller/MailfilterController.php create mode 100644 lib/Exception/FilterParserException.php create mode 100644 lib/Service/AllowedRecipientsService.php create mode 100644 lib/Service/MailFilter/FilterBuilder.php create mode 100644 lib/Service/MailFilter/FilterParser.php create mode 100644 lib/Service/MailFilter/FilterParserResult.php create mode 100644 lib/Service/MailFilter/FilterState.php create mode 100644 lib/Service/MailFilterService.php create mode 100644 src/components/mailFilter/MailFilterAction.vue create mode 100644 src/components/mailFilter/MailFilterActionAddflag.vue create mode 100644 src/components/mailFilter/MailFilterActionFileinto.vue create mode 100644 src/components/mailFilter/MailFilterDeleteModal.vue create mode 100644 src/components/mailFilter/MailFilterOperator.vue create mode 100644 src/components/mailFilter/MailFilterTest.vue create mode 100644 src/components/mailFilter/MailFilterUpdateModal.vue create mode 100644 src/components/mailFilter/MailFilters.vue create mode 100644 src/service/MailFilterService.js create mode 100644 src/store/mailFilterStore.js create mode 100644 tests/Unit/Service/AllowedRecipientsServiceTest.php create mode 100644 tests/Unit/Service/MailFilter/FilterBuilderTest.php create mode 100644 tests/Unit/Service/MailFilter/FilterParserTest.php create mode 100644 tests/data/mail-filter/builder1.json create mode 100644 tests/data/mail-filter/builder1.sieve create mode 100644 tests/data/mail-filter/builder2.json create mode 100644 tests/data/mail-filter/builder2.sieve create mode 100644 tests/data/mail-filter/builder3.json create mode 100644 tests/data/mail-filter/builder3.sieve create mode 100644 tests/data/mail-filter/builder4.json create mode 100644 tests/data/mail-filter/builder4.sieve create mode 100644 tests/data/mail-filter/builder5.json create mode 100644 tests/data/mail-filter/builder5.sieve create mode 100644 tests/data/mail-filter/builder6.json create mode 100644 tests/data/mail-filter/builder6.sieve create mode 100644 tests/data/mail-filter/parser1.sieve create mode 100644 tests/data/mail-filter/parser2.sieve diff --git a/REUSE.toml b/REUSE.toml index df7fb28efa..ba16f7ae7d 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -101,6 +101,12 @@ precedence = "aggregate" SPDX-FileCopyrightText = "2024 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" +[[annotations]] +path = ["tests/data/mail-filter"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2024 Nextcloud GmbH and Nextcloud contributors" +SPDX-License-Identifier = "AGPL-3.0-or-later" + [[annotations]] path = ".github/CODEOWNERS" precedence = "aggregate" diff --git a/lib/Controller/MailfilterController.php b/lib/Controller/MailfilterController.php new file mode 100644 index 0000000000..7609c0559a --- /dev/null +++ b/lib/Controller/MailfilterController.php @@ -0,0 +1,58 @@ +currentUserId = $userId; + } + + #[Route(Route::TYPE_FRONTPAGE, verb: 'GET', url: '/api/mailfilter/{accountId}', requirements: ['accountId' => '[\d]+'])] + public function getFilters(int $accountId) { + $account = $this->accountService->findById($accountId); + + if ($account->getUserId() !== $this->currentUserId) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + $result = $this->mailFilterService->parse($account->getMailAccount()); + + return new JSONResponse($result->getFilters()); + } + + #[Route(Route::TYPE_FRONTPAGE, verb: 'PUT', url: '/api/mailfilter/{accountId}', requirements: ['accountId' => '[\d]+'])] + public function updateFilters(int $accountId, array $filters) { + $account = $this->accountService->findById($accountId); + + if ($account->getUserId() !== $this->currentUserId) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + $this->mailFilterService->update($account->getMailAccount(), $filters); + + return new JSONResponse([]); + } +} diff --git a/lib/Exception/FilterParserException.php b/lib/Exception/FilterParserException.php new file mode 100644 index 0000000000..a6ab6d8669 --- /dev/null +++ b/lib/Exception/FilterParserException.php @@ -0,0 +1,29 @@ +getMessage(), + 0, + $exception, + ); + } + + public static function invalidState(): FilterParserException { + return new self( + 'Reached an invalid state', + ); + } +} diff --git a/lib/Service/AllowedRecipientsService.php b/lib/Service/AllowedRecipientsService.php new file mode 100644 index 0000000000..f97e03338a --- /dev/null +++ b/lib/Service/AllowedRecipientsService.php @@ -0,0 +1,34 @@ + $alias->getAlias(), + $this->aliasesService->findAll($mailAccount->getId(), $mailAccount->getUserId()) + ); + + return array_merge([$mailAccount->getEmail()], $aliases); + } +} diff --git a/lib/Service/MailFilter/FilterBuilder.php b/lib/Service/MailFilter/FilterBuilder.php new file mode 100644 index 0000000000..970bf7e5c5 --- /dev/null +++ b/lib/Service/MailFilter/FilterBuilder.php @@ -0,0 +1,154 @@ +sanitizeFlag($action['flag'])) + ); + } + if ($action['type'] === 'keep') { + $actions[] = 'keep;'; + } + if ($action['type'] === 'stop') { + $actions[] = 'stop;'; + } + } + + if (count($tests) > 1) { + $ifTest = sprintf('%s (%s)', $filter['operator'], implode(', ', $tests)); + } else { + $ifTest = $tests[0]; + } + + $ifBlock = sprintf( + "if %s {\r\n%s\r\n}", + $ifTest, + implode(self::SIEVE_NEWLINE, $actions) + ); + + $commands[] = $ifBlock; + } + + $extensions = array_unique($extensions); + $requireSection = []; + + if (count($extensions) > 0) { + $requireSection[] = self::SEPARATOR; + $requireSection[] = 'require ' . SieveUtils::stringList($extensions) . ';'; + $requireSection[] = self::SEPARATOR; + } + + $stateJsonString = json_encode($this->sanitizeDefinition($filters), JSON_THROW_ON_ERROR); + + $filterSection = [ + self::SEPARATOR, + self::DATA_MARKER . $stateJsonString, + ...$commands, + self::SEPARATOR, + ]; + + return implode(self::SIEVE_NEWLINE, array_merge( + $requireSection, + [$untouchedScript], + $filterSection, + )); + } + + private function sanitizeFlag(string $flag): string { + try { + return $this->imapFlag->create($flag); + } catch (ImapFlagEncodingException) { + return 'placeholder_for_invalid_label'; + } + } + + private function sanitizeDefinition(array $filters): array { + return array_map(static function ($filter) { + unset($filter['accountId'], $filter['id']); + $filter['tests'] = array_map(static function ($test) { + unset($test['id']); + return $test; + }, $filter['tests']); + $filter['actions'] = array_map(static function ($action) { + unset($action['id']); + return $action; + }, $filter['actions']); + $filter['priority'] = (int)$filter['priority']; + return $filter; + }, $filters); + } +} diff --git a/lib/Service/MailFilter/FilterParser.php b/lib/Service/MailFilter/FilterParser.php new file mode 100644 index 0000000000..668134b081 --- /dev/null +++ b/lib/Service/MailFilter/FilterParser.php @@ -0,0 +1,62 @@ +filters; + } + + public function getSieveScript(): string { + return $this->sieveScript; + } + + public function getUntouchedSieveScript(): string { + return $this->untouchedSieveScript; + } + + #[ReturnTypeWillChange] + public function jsonSerialize() { + return [ + 'filters' => $this->filters, + 'script' => $this->getSieveScript(), + 'untouchedScript' => $this->getUntouchedSieveScript(), + ]; + } +} diff --git a/lib/Service/MailFilter/FilterState.php b/lib/Service/MailFilter/FilterState.php new file mode 100644 index 0000000000..e238da2c57 --- /dev/null +++ b/lib/Service/MailFilter/FilterState.php @@ -0,0 +1,35 @@ +filters; + } + + #[ReturnTypeWillChange] + public function jsonSerialize() { + return $this->filters; + } +} diff --git a/lib/Service/MailFilterService.php b/lib/Service/MailFilterService.php new file mode 100644 index 0000000000..46a23513ba --- /dev/null +++ b/lib/Service/MailFilterService.php @@ -0,0 +1,89 @@ +sieveService->getActiveScript($account->getUserId(), $account->getId()); + return $this->filterParser->parseFilterState($script->getScript()); + } + + /** + * @throws CouldNotConnectException + * @throws JsonException + * @throws ClientException + * @throws OutOfOfficeParserException + * @throws ManageSieveException + * @throws FilterParserException + */ + public function update(MailAccount $account, array $filters): void { + $script = $this->sieveService->getActiveScript($account->getUserId(), $account->getId()); + + $oooResult = $this->outOfOfficeParser->parseOutOfOfficeState($script->getScript()); + $filterResult = $this->filterParser->parseFilterState($oooResult->getUntouchedSieveScript()); + + $newScript = $this->filterBuilder->buildSieveScript( + $filters, + $filterResult->getUntouchedSieveScript() + ); + + $oooState = $oooResult->getState(); + + if ($oooState === null) { + $newScriptWithOutOfOffice = $newScript; + } else { + $newScriptWithOutOfOffice = $this->outOfOfficeParser->buildSieveScript( + $oooState, + $newScript, + $this->allowedRecipientsService->get($account), + ); + } + + try { + $this->sieveService->updateActiveScript($account->getUserId(), $account->getId(), $newScriptWithOutOfOffice); + } catch (ManageSieveException $e) { + $this->logger->error('Failed to save sieve script: ' . $e->getMessage(), [ + 'exception' => $e, + 'script' => $newScript, + ]); + throw $e; + } + } +} diff --git a/lib/Service/OutOfOffice/OutOfOfficeParser.php b/lib/Service/OutOfOffice/OutOfOfficeParser.php index 79a532514f..28bb2abfd7 100644 --- a/lib/Service/OutOfOffice/OutOfOfficeParser.php +++ b/lib/Service/OutOfOffice/OutOfOfficeParser.php @@ -13,6 +13,7 @@ use DateTimeZone; use JsonException; use OCA\Mail\Exception\OutOfOfficeParserException; +use OCA\Mail\Sieve\SieveUtils; /** * Parses and builds out-of-office states from/to sieve scripts. @@ -119,7 +120,7 @@ public function buildSieveScript( $condition = "currentdate :value \"ge\" \"iso8601\" \"$formattedStart\""; } - $escapedSubject = $this->escapeStringForSieve($state->getSubject()); + $escapedSubject = SieveUtils::escapeString($state->getSubject()); $vacation = [ 'vacation', ':days 4', @@ -134,7 +135,7 @@ public function buildSieveScript( $vacation[] = ":addresses [$joinedRecipients]"; } - $escapedMessage = $this->escapeStringForSieve($state->getMessage()); + $escapedMessage = SieveUtils::escapeString($state->getMessage()); $vacation[] = "\"$escapedMessage\""; $vacationCommand = implode(' ', $vacation); @@ -183,9 +184,4 @@ public function buildSieveScript( private function formatDateForSieve(DateTimeImmutable $date): string { return $date->setTimezone($this->utc)->format('Y-m-d\TH:i:s\Z'); } - - private function escapeStringForSieve(string $subject): string { - $subject = preg_replace('/\\\\/', '\\\\\\\\', $subject); - return preg_replace('/"/', '\\"', $subject); - } } diff --git a/lib/Service/OutOfOfficeService.php b/lib/Service/OutOfOfficeService.php index c12569b5f0..f209f021d1 100644 --- a/lib/Service/OutOfOfficeService.php +++ b/lib/Service/OutOfOfficeService.php @@ -13,7 +13,6 @@ use Horde\ManageSieve\Exception as ManageSieveException; use InvalidArgumentException; use JsonException; -use OCA\Mail\Db\Alias; use OCA\Mail\Db\MailAccount; use OCA\Mail\Exception\ClientException; use OCA\Mail\Exception\CouldNotConnectException; @@ -36,8 +35,8 @@ public function __construct( private OutOfOfficeParser $outOfOfficeParser, private SieveService $sieveService, private LoggerInterface $logger, - private AliasesService $aliasesService, private ITimeFactory $timeFactory, + private AllowedRecipientsService $allowedRecipientsService, ContainerInterface $container, ) { // TODO: inject directly if we only support Nextcloud >= 28 @@ -72,7 +71,7 @@ public function update(MailAccount $account, OutOfOfficeState $state): void { $newScript = $this->outOfOfficeParser->buildSieveScript( $state, $oldState->getUntouchedSieveScript(), - $this->buildAllowedRecipients($account), + $this->allowedRecipientsService->get($account), ); try { $this->sieveService->updateActiveScript($account->getUserId(), $account->getId(), $newScript); @@ -155,15 +154,4 @@ public function disable(MailAccount $account): void { $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/src/components/AccountSettings.vue b/src/components/AccountSettings.vue index 703ee899a0..d4a61327f6 100644 --- a/src/components/AccountSettings.vue +++ b/src/components/AccountSettings.vue @@ -55,6 +55,13 @@ {{ t('mail', 'Please connect to a sieve server first.') }}

+ +
+ +
+
@@ -104,6 +111,7 @@ import CertificateSettings from './CertificateSettings.vue' import SearchSettings from './SearchSettings.vue' import TrashRetentionSettings from './TrashRetentionSettings.vue' import logger from '../logger.js' +import MailFilters from './mailFilter/MailFilters.vue' export default { name: 'AccountSettings', @@ -121,6 +129,7 @@ export default { CertificateSettings, TrashRetentionSettings, SearchSettings, + MailFilters, }, props: { account: { diff --git a/src/components/mailFilter/MailFilterAction.vue b/src/components/mailFilter/MailFilterAction.vue new file mode 100644 index 0000000000..58f5bda10e --- /dev/null +++ b/src/components/mailFilter/MailFilterAction.vue @@ -0,0 +1,96 @@ + + + + diff --git a/src/components/mailFilter/MailFilterActionAddflag.vue b/src/components/mailFilter/MailFilterActionAddflag.vue new file mode 100644 index 0000000000..40658f1e83 --- /dev/null +++ b/src/components/mailFilter/MailFilterActionAddflag.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/components/mailFilter/MailFilterActionFileinto.vue b/src/components/mailFilter/MailFilterActionFileinto.vue new file mode 100644 index 0000000000..36609cdc18 --- /dev/null +++ b/src/components/mailFilter/MailFilterActionFileinto.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/components/mailFilter/MailFilterDeleteModal.vue b/src/components/mailFilter/MailFilterDeleteModal.vue new file mode 100644 index 0000000000..a98f6eeffe --- /dev/null +++ b/src/components/mailFilter/MailFilterDeleteModal.vue @@ -0,0 +1,61 @@ + + + + diff --git a/src/components/mailFilter/MailFilterOperator.vue b/src/components/mailFilter/MailFilterOperator.vue new file mode 100644 index 0000000000..34af5ee0da --- /dev/null +++ b/src/components/mailFilter/MailFilterOperator.vue @@ -0,0 +1,54 @@ + + + + diff --git a/src/components/mailFilter/MailFilterTest.vue b/src/components/mailFilter/MailFilterTest.vue new file mode 100644 index 0000000000..bd4c2844f3 --- /dev/null +++ b/src/components/mailFilter/MailFilterTest.vue @@ -0,0 +1,125 @@ + + + + diff --git a/src/components/mailFilter/MailFilterUpdateModal.vue b/src/components/mailFilter/MailFilterUpdateModal.vue new file mode 100644 index 0000000000..63d64b6b64 --- /dev/null +++ b/src/components/mailFilter/MailFilterUpdateModal.vue @@ -0,0 +1,178 @@ + + + + diff --git a/src/components/mailFilter/MailFilters.vue b/src/components/mailFilter/MailFilters.vue new file mode 100644 index 0000000000..43083ca539 --- /dev/null +++ b/src/components/mailFilter/MailFilters.vue @@ -0,0 +1,216 @@ + + + + + + diff --git a/src/service/MailFilterService.js b/src/service/MailFilterService.js new file mode 100644 index 0000000000..542fbab3fd --- /dev/null +++ b/src/service/MailFilterService.js @@ -0,0 +1,20 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { generateUrl } from '@nextcloud/router' +import axios from '@nextcloud/axios' + +export async function getFilters(accountId) { + const url = generateUrl('/apps/mail/api/mailfilter/{accountId}', { accountId }) + + const { data } = await axios.get(url) + return data.data +} + +export async function updateFilters(accountId, filters) { + const url = generateUrl('/apps/mail/api/mailfilter/{accountId}', { accountId }) + + const { data } = await axios.put(url, { filters }) + return data.data +} diff --git a/src/store/mailFilterStore.js b/src/store/mailFilterStore.js new file mode 100644 index 0000000000..447dc25b66 --- /dev/null +++ b/src/store/mailFilterStore.js @@ -0,0 +1,60 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { defineStore } from 'pinia' +import * as MailFilterService from '../service/MailFilterService.js' +import { randomId } from '../util/randomId.js' + +export default defineStore('mailFilter', { + state: () => { + return { + filters: [], + } + }, + actions: { + async fetch(accountId) { + await this.$patch(async (state) => { + const filters = await MailFilterService.getFilters(accountId) + if (filters) { + state.filters = filters.map((filter) => { + filter.id = randomId() + filter.tests.map((test) => { + test.id = randomId() + if (!test.hasOwnProperty('values')) { + test.values = [test.value] + } + return test + }) + filter.actions.map((action) => { + action.id = randomId() + return action + }) + if (!filter.hasOwnProperty('priority')) { + filter.priority = 0 + } + return filter + }) + } + }) + }, + async update(accountId) { + let filters = structuredClone(this.filters) + filters = filters.map((filter) => { + delete filter.id + filter.tests.map((test) => { + delete test.id + return test + }) + filter.actions.map((action) => { + delete action.id + return action + }) + return filter + }) + + await MailFilterService.updateFilters(accountId, filters) + }, + }, +}) diff --git a/tests/Unit/Service/AllowedRecipientsServiceTest.php b/tests/Unit/Service/AllowedRecipientsServiceTest.php new file mode 100644 index 0000000000..ad064d52de --- /dev/null +++ b/tests/Unit/Service/AllowedRecipientsServiceTest.php @@ -0,0 +1,54 @@ +aliasesService = $this->createMock(AliasesService::class); + $this->allowedRecipientsService = new AllowedRecipientsService($this->aliasesService); + } + + public function testGet(): void { + $alias1 = new Alias(); + $alias1->setAlias('alias1@example.org'); + + $alias2 = new Alias(); + $alias2->setAlias('alias2@example.org'); + + $this->aliasesService->expects(self::once()) + ->method('findAll') + ->willReturn([$alias1, $alias2]); + + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setUserId('user'); + $mailAccount->setEmail('user@example.org'); + + $recipients = $this->allowedRecipientsService->get($mailAccount); + + $this->assertCount(3, $recipients); + $this->assertEquals('user@example.org', $recipients[0]); + $this->assertEquals('alias1@example.org', $recipients[1]); + $this->assertEquals('alias2@example.org', $recipients[2]); + } +} diff --git a/tests/Unit/Service/MailFilter/FilterBuilderTest.php b/tests/Unit/Service/MailFilter/FilterBuilderTest.php new file mode 100644 index 0000000000..5f2ce94c0a --- /dev/null +++ b/tests/Unit/Service/MailFilter/FilterBuilderTest.php @@ -0,0 +1,65 @@ +testFolder = __DIR__ . '/../../../data/mail-filter/'; + } + + public function setUp(): void { + parent::setUp(); + $this->builder = new FilterBuilder(new ImapFlag()); + } + + /** + * @dataProvider dataBuild + */ + public function testBuild(string $testName): void { + $untouchedScript = '# Hello, this is a test'; + + $filters = json_decode( + file_get_contents($this->testFolder . $testName . '.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + + $script = $this->builder->buildSieveScript($filters, $untouchedScript); + + // the .sieve files have \r\n line endings + $script .= "\r\n"; + + $this->assertStringEqualsFile( + $this->testFolder . $testName . '.sieve', + $script + ); + } + + public function dataBuild(): array { + $files = glob($this->testFolder . 'builder*.json'); + $tests = []; + + foreach($files as $file) { + $filename = pathinfo($file, PATHINFO_FILENAME); + $tests[$filename] = [$filename]; + } + + return $tests; + } +} diff --git a/tests/Unit/Service/MailFilter/FilterParserTest.php b/tests/Unit/Service/MailFilter/FilterParserTest.php new file mode 100644 index 0000000000..83e775df64 --- /dev/null +++ b/tests/Unit/Service/MailFilter/FilterParserTest.php @@ -0,0 +1,77 @@ +testFolder = __DIR__ . '/../../../data/mail-filter/'; + } + + protected function setUp(): void { + parent::setUp(); + + $this->filterParser = new FilterParser(); + } + + public function testParse1(): void { + $script = file_get_contents($this->testFolder . 'parser1.sieve'); + + $state = $this->filterParser->parseFilterState($script); + $filters = $state->getFilters(); + + $this->assertCount(1, $filters); + $this->assertSame('Test 1', $filters[0]['name']); + $this->assertTrue($filters[0]['enable']); + $this->assertSame('allof', $filters[0]['operator']); + $this->assertSame(10, $filters[0]['priority']); + + $this->assertCount(1, $filters[0]['tests']); + $this->assertSame('from', $filters[0]['tests'][0]['field']); + $this->assertSame('is', $filters[0]['tests'][0]['operator']); + $this->assertEquals(['alice@example.org', 'bob@example.org'], $filters[0]['tests'][0]['values']); + + $this->assertCount(1, $filters[0]['actions']); + $this->assertSame('addflag', $filters[0]['actions'][0]['type']); + $this->assertSame('Alice and Bob', $filters[0]['actions'][0]['flag']); + } + + public function testParse2(): void { + $script = file_get_contents($this->testFolder . 'parser2.sieve'); + + $state = $this->filterParser->parseFilterState($script); + $filters = $state->getFilters(); + + $this->assertCount(1, $filters); + $this->assertSame('Test 2', $filters[0]['name']); + $this->assertTrue($filters[0]['enable']); + $this->assertSame('anyof', $filters[0]['operator']); + $this->assertSame(20, $filters[0]['priority']); + + $this->assertCount(2, $filters[0]['tests']); + $this->assertSame('subject', $filters[0]['tests'][0]['field']); + $this->assertSame('contains', $filters[0]['tests'][0]['operator']); + $this->assertEquals(['Project-A', 'Project-B'], $filters[0]['tests'][0]['values']); + $this->assertSame('from', $filters[0]['tests'][1]['field']); + $this->assertSame('is', $filters[0]['tests'][1]['operator']); + $this->assertEquals(['john@example.org'], $filters[0]['tests'][1]['values']); + + $this->assertCount(1, $filters[0]['actions']); + $this->assertSame('fileinto', $filters[0]['actions'][0]['type']); + $this->assertSame('Test Data', $filters[0]['actions'][0]['mailbox']); + } +} diff --git a/tests/Unit/Service/OutOfOfficeServiceTest.php b/tests/Unit/Service/OutOfOfficeServiceTest.php index 1e3aa91ec7..d2afbb7631 100644 --- a/tests/Unit/Service/OutOfOfficeServiceTest.php +++ b/tests/Unit/Service/OutOfOfficeServiceTest.php @@ -151,11 +151,11 @@ public function testUpdateFromSystemWithEnabledOutOfOffice(?IOutOfOfficeData $da ['email@domain.com'], ) ->willReturn('# new sieve script'); - $aliasesService = $this->serviceMock->getParameter('aliasesService'); - $aliasesService->expects(self::once()) - ->method('findAll') - ->with(1, 'user') - ->willReturn([]); + $allowedRecipientsService = $this->serviceMock->getParameter('allowedRecipientsService'); + $allowedRecipientsService->expects(self::once()) + ->method('get') + ->with($mailAccount) + ->willReturn(['email@domain.com']); $sieveService->expects(self::once()) ->method('updateActiveScript') ->with('user', 1, '# new sieve script'); @@ -229,11 +229,11 @@ public function testUpdateFromSystemWithDisabledOutOfOffice(?IOutOfOfficeData $d ['email@domain.com'], ) ->willReturn('# new sieve script'); - $aliasesService = $this->serviceMock->getParameter('aliasesService'); - $aliasesService->expects(self::once()) - ->method('findAll') - ->with(1, 'user') - ->willReturn([]); + $allowedRecipientsService = $this->serviceMock->getParameter('allowedRecipientsService'); + $allowedRecipientsService->expects(self::once()) + ->method('get') + ->with($mailAccount) + ->willReturn(['email@domain.com']); $sieveService->expects(self::once()) ->method('updateActiveScript') ->with('user', 1, '# new sieve script'); diff --git a/tests/data/mail-filter/builder1.json b/tests/data/mail-filter/builder1.json new file mode 100644 index 0000000000..35ac4a6709 --- /dev/null +++ b/tests/data/mail-filter/builder1.json @@ -0,0 +1,24 @@ +[ + { + "name": "Test 1", + "enable": true, + "operator": "allof", + "tests": [ + { + "operator": "is", + "values": [ + "alice@example.org", + "bob@example.org" + ], + "field": "from" + } + ], + "actions": [ + { + "type": "addflag", + "flag": "Alice and Bob" + } + ], + "priority": 10 + } +] diff --git a/tests/data/mail-filter/builder1.sieve b/tests/data/mail-filter/builder1.sieve new file mode 100644 index 0000000000..657d20ed2a --- /dev/null +++ b/tests/data/mail-filter/builder1.sieve @@ -0,0 +1,11 @@ +### Nextcloud Mail: Filters ### DON'T EDIT ### +require ["imap4flags"]; +### Nextcloud Mail: Filters ### DON'T EDIT ### +# Hello, this is a test +### Nextcloud Mail: Filters ### DON'T EDIT ### +# DATA: [{"name":"Test 1","enable":true,"operator":"allof","tests":[{"operator":"is","values":["alice@example.org","bob@example.org"],"field":"from"}],"actions":[{"type":"addflag","flag":"Alice and Bob"}],"priority":10}] +# Filter: Test 1 +if address :is :all "From" ["alice@example.org", "bob@example.org"] { +addflag "$alice_and_bob"; +} +### Nextcloud Mail: Filters ### DON'T EDIT ### diff --git a/tests/data/mail-filter/builder2.json b/tests/data/mail-filter/builder2.json new file mode 100644 index 0000000000..d431502edc --- /dev/null +++ b/tests/data/mail-filter/builder2.json @@ -0,0 +1,31 @@ +[ + { + "name": "Test 2", + "enable": true, + "operator": "anyof", + "tests": [ + { + "operator": "contains", + "values": [ + "Project-A", + "Project-B" + ], + "field": "subject" + }, + { + "operator": "is", + "values": [ + "john@example.org" + ], + "field": "from" + } + ], + "actions": [ + { + "type": "fileinto", + "mailbox": "Test Data" + } + ], + "priority": "20" + } +] diff --git a/tests/data/mail-filter/builder2.sieve b/tests/data/mail-filter/builder2.sieve new file mode 100644 index 0000000000..8e7355adf7 --- /dev/null +++ b/tests/data/mail-filter/builder2.sieve @@ -0,0 +1,11 @@ +### Nextcloud Mail: Filters ### DON'T EDIT ### +require ["fileinto"]; +### Nextcloud Mail: Filters ### DON'T EDIT ### +# Hello, this is a test +### Nextcloud Mail: Filters ### DON'T EDIT ### +# DATA: [{"name":"Test 2","enable":true,"operator":"anyof","tests":[{"operator":"contains","values":["Project-A","Project-B"],"field":"subject"},{"operator":"is","values":["john@example.org"],"field":"from"}],"actions":[{"type":"fileinto","mailbox":"Test Data"}],"priority":20}] +# Filter: Test 2 +if anyof (header :contains "Subject" ["Project-A", "Project-B"], address :is :all "From" ["john@example.org"]) { +fileinto "Test Data"; +} +### Nextcloud Mail: Filters ### DON'T EDIT ### diff --git a/tests/data/mail-filter/builder3.json b/tests/data/mail-filter/builder3.json new file mode 100644 index 0000000000..dd7b4583d7 --- /dev/null +++ b/tests/data/mail-filter/builder3.json @@ -0,0 +1,55 @@ +[ + { + "name": "Test 3.1", + "enable": true, + "operator": "anyof", + "tests": [ + { + "operator": "contains", + "values": [ + "Project-A", + "Project-B" + ], + "field": "subject" + }, + { + "operator": "is", + "values": [ + "john@example.org" + ], + "field": "from" + } + ], + "actions": [ + { + "type": "fileinto", + "mailbox": "Test Data" + }, + { + "type": "stop" + } + ], + "priority": "20" + }, + { + "name": "Test 3.2", + "enable": true, + "operator": "allof", + "tests": [ + { + "operator": "contains", + "values": [ + "@example.org" + ], + "field": "to" + } + ], + "actions": [ + { + "type": "addflag", + "flag": "Test A" + } + ], + "priority": 30 + } +] diff --git a/tests/data/mail-filter/builder3.sieve b/tests/data/mail-filter/builder3.sieve new file mode 100644 index 0000000000..9c77f4726e --- /dev/null +++ b/tests/data/mail-filter/builder3.sieve @@ -0,0 +1,16 @@ +### Nextcloud Mail: Filters ### DON'T EDIT ### +require ["fileinto", "imap4flags"]; +### Nextcloud Mail: Filters ### DON'T EDIT ### +# Hello, this is a test +### Nextcloud Mail: Filters ### DON'T EDIT ### +# DATA: [{"name":"Test 3.1","enable":true,"operator":"anyof","tests":[{"operator":"contains","values":["Project-A","Project-B"],"field":"subject"},{"operator":"is","values":["john@example.org"],"field":"from"}],"actions":[{"type":"fileinto","mailbox":"Test Data"},{"type":"stop"}],"priority":20},{"name":"Test 3.2","enable":true,"operator":"allof","tests":[{"operator":"contains","values":["@example.org"],"field":"to"}],"actions":[{"type":"addflag","flag":"Test A"}],"priority":30}] +# Filter: Test 3.1 +if anyof (header :contains "Subject" ["Project-A", "Project-B"], address :is :all "From" ["john@example.org"]) { +fileinto "Test Data"; +stop; +} +# Filter: Test 3.2 +if address :contains :all "To" ["@example.org"] { +addflag "$test_a"; +} +### Nextcloud Mail: Filters ### DON'T EDIT ### diff --git a/tests/data/mail-filter/builder4.json b/tests/data/mail-filter/builder4.json new file mode 100644 index 0000000000..c02d4c469d --- /dev/null +++ b/tests/data/mail-filter/builder4.json @@ -0,0 +1,15 @@ +[ + { + "actions": [ + { + "flag": "Flag 123", + "type": "addflag" + } + ], + "enable": true, + "name": "Test 4", + "operator": "allof", + "priority": 60, + "tests": [] + } +] diff --git a/tests/data/mail-filter/builder4.sieve b/tests/data/mail-filter/builder4.sieve new file mode 100644 index 0000000000..22432c66c7 --- /dev/null +++ b/tests/data/mail-filter/builder4.sieve @@ -0,0 +1,6 @@ +# Hello, this is a test +### Nextcloud Mail: Filters ### DON'T EDIT ### +# DATA: [{"actions":[{"flag":"Flag 123","type":"addflag"}],"enable":true,"name":"Test 4","operator":"allof","priority":60,"tests":[]}] +# Filter: Test 4 +# No valid tests found +### Nextcloud Mail: Filters ### DON'T EDIT ### diff --git a/tests/data/mail-filter/builder5.json b/tests/data/mail-filter/builder5.json new file mode 100644 index 0000000000..9cc8c89f45 --- /dev/null +++ b/tests/data/mail-filter/builder5.json @@ -0,0 +1,27 @@ +[ + { + "actions": [ + { + "flag": "Report", + "type": "addflag" + }, + { + "flag": "To read", + "type": "addflag" + } + ], + "enable": true, + "name": "Test 5", + "operator": "allof", + "priority": 10, + "tests": [ + { + "field": "subject", + "operator": "matches", + "values": [ + "work*report" + ] + } + ] + } +] diff --git a/tests/data/mail-filter/builder5.sieve b/tests/data/mail-filter/builder5.sieve new file mode 100644 index 0000000000..0beda57efe --- /dev/null +++ b/tests/data/mail-filter/builder5.sieve @@ -0,0 +1,12 @@ +### Nextcloud Mail: Filters ### DON'T EDIT ### +require ["imap4flags"]; +### Nextcloud Mail: Filters ### DON'T EDIT ### +# Hello, this is a test +### Nextcloud Mail: Filters ### DON'T EDIT ### +# DATA: [{"actions":[{"flag":"Report","type":"addflag"},{"flag":"To read","type":"addflag"}],"enable":true,"name":"Test 5","operator":"allof","priority":10,"tests":[{"field":"subject","operator":"matches","values":["work*report"]}]}] +# Filter: Test 5 +if header :matches "Subject" ["work*report"] { +addflag "$report"; +addflag "$to_read"; +} +### Nextcloud Mail: Filters ### DON'T EDIT ### diff --git a/tests/data/mail-filter/builder6.json b/tests/data/mail-filter/builder6.json new file mode 100644 index 0000000000..82c8131c1c --- /dev/null +++ b/tests/data/mail-filter/builder6.json @@ -0,0 +1,36 @@ +[ + { + "actions": [ + { + "mailbox": "Test Data", + "type": "fileinto" + }, + { + "flag": "Projects\\Reporting", + "type": "addflag" + } + ], + "enable": true, + "name": "Test 6", + "operator": "anyof", + "priority": 10, + "tests": [ + { + "field": "subject", + "operator": "is", + "values": [ + "\"Project-A\"", + "Project\\A" + ] + }, + { + "field": "subject", + "operator": "is", + "values": [ + "\"Project-B\"", + "Project\\B" + ] + } + ] + } +] diff --git a/tests/data/mail-filter/builder6.sieve b/tests/data/mail-filter/builder6.sieve new file mode 100644 index 0000000000..bbef8e9ff7 --- /dev/null +++ b/tests/data/mail-filter/builder6.sieve @@ -0,0 +1,12 @@ +### Nextcloud Mail: Filters ### DON'T EDIT ### +require ["fileinto", "imap4flags"]; +### Nextcloud Mail: Filters ### DON'T EDIT ### +# Hello, this is a test +### Nextcloud Mail: Filters ### DON'T EDIT ### +# DATA: [{"actions":[{"mailbox":"Test Data","type":"fileinto"},{"flag":"Projects\\Reporting","type":"addflag"}],"enable":true,"name":"Test 6","operator":"anyof","priority":10,"tests":[{"field":"subject","operator":"is","values":["\"Project-A\"","Project\\A"]},{"field":"subject","operator":"is","values":["\"Project-B\"","Project\\B"]}]}] +# Filter: Test 6 +if anyof (header :is "Subject" ["\"Project-A\"", "Project\\A"], header :is "Subject" ["\"Project-B\"", "Project\\B"]) { +fileinto "Test Data"; +addflag "$projects\\reporting"; +} +### Nextcloud Mail: Filters ### DON'T EDIT ### diff --git a/tests/data/mail-filter/parser1.sieve b/tests/data/mail-filter/parser1.sieve new file mode 100644 index 0000000000..657d20ed2a --- /dev/null +++ b/tests/data/mail-filter/parser1.sieve @@ -0,0 +1,11 @@ +### Nextcloud Mail: Filters ### DON'T EDIT ### +require ["imap4flags"]; +### Nextcloud Mail: Filters ### DON'T EDIT ### +# Hello, this is a test +### Nextcloud Mail: Filters ### DON'T EDIT ### +# DATA: [{"name":"Test 1","enable":true,"operator":"allof","tests":[{"operator":"is","values":["alice@example.org","bob@example.org"],"field":"from"}],"actions":[{"type":"addflag","flag":"Alice and Bob"}],"priority":10}] +# Filter: Test 1 +if address :is :all "From" ["alice@example.org", "bob@example.org"] { +addflag "$alice_and_bob"; +} +### Nextcloud Mail: Filters ### DON'T EDIT ### diff --git a/tests/data/mail-filter/parser2.sieve b/tests/data/mail-filter/parser2.sieve new file mode 100644 index 0000000000..9fbf715fb8 --- /dev/null +++ b/tests/data/mail-filter/parser2.sieve @@ -0,0 +1,11 @@ +### Nextcloud Mail: Filters ### DON'T EDIT ### +require ["fileinto"]; +### Nextcloud Mail: Filters ### DON'T EDIT ### +# Hello, this is a test +### Nextcloud Mail: Filters ### DON'T EDIT ### +# DATA: [{"name":"Test 2","enable":true,"operator":"anyof","tests":[{"operator":"contains","values":["Project-A","Project-B"],"field":"subject"},{"operator":"is","values":["john@example.org"],"field":"from"}],"actions":[{"type":"fileinto","flag":"","mailbox":"Test Data"}],"priority":20}] +# Filter: Test 2 +if anyof (header :contains "Subject" ["Project-A", "Project-B"], address :is :all "From" ["john@example.org"]) { +fileinto "Test Data"; +} +### Nextcloud Mail: Filters ### DON'T EDIT ###