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 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('mail', 'Delete action') }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
{{ t('mail', 'Operator') }}
+
+
+
+ {{ t('mail', 'allof') }}
+
+
+ {{ t('mail', 'anyof') }}
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('mail', 'Delete test') }}
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ t('mail', 'Filter is active') }}
+ {{ t('mail', 'Filter is not active') }}
+
+
+
+
+
+
+ {{ t('mail', 'Delete filter') }}
+
+
+
+
+
+ {{ t('mail', 'New filter') }}
+
+
+
+
+
+
+
+
+
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 ###