From df990d772d9a9f26cce4e4ced74a7b1d3e439369 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Wed, 11 Sep 2024 18:21:12 +0200 Subject: [PATCH 1/6] refactor: move imap flag encoding to helper Signed-off-by: Daniel Kesselberg --- lib/Exception/ImapFlagEncodingException.php | 21 ++++++++++ lib/IMAP/ImapFlag.php | 28 +++++++++++++ lib/Service/MailManager.php | 14 ++++--- tests/Unit/IMAP/ImapFlagTest.php | 46 +++++++++++++++++++++ tests/Unit/Service/MailManagerTest.php | 6 ++- 5 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 lib/Exception/ImapFlagEncodingException.php create mode 100644 lib/IMAP/ImapFlag.php create mode 100644 tests/Unit/IMAP/ImapFlagTest.php diff --git a/lib/Exception/ImapFlagEncodingException.php b/lib/Exception/ImapFlagEncodingException.php new file mode 100644 index 0000000000..5fd2e08bf0 --- /dev/null +++ b/lib/Exception/ImapFlagEncodingException.php @@ -0,0 +1,21 @@ +imapClientFactory = $imapClientFactory; $this->mailboxMapper = $mailboxMapper; $this->mailboxSync = $mailboxSync; @@ -795,12 +798,11 @@ public function isPermflagsEnabled(Horde_Imap_Client_Socket $client, Account $ac } public function createTag(string $displayName, string $color, string $userId): Tag { - $imapLabel = str_replace(' ', '_', $displayName); - $imapLabel = mb_convert_encoding($imapLabel, 'UTF7-IMAP', 'UTF-8'); - if ($imapLabel === false) { - throw new ClientException('Error converting display name to UTF7-IMAP ', 0); + try { + $imapLabel = $this->imapFlag->create($displayName); + } catch (ImapFlagEncodingException $e) { + throw new ClientException('Error converting display name to UTF7-IMAP ', 0, $e); } - $imapLabel = '$' . strtolower(mb_strcut($imapLabel, 0, 63)); try { return $this->getTagByImapLabel($imapLabel, $userId); diff --git a/tests/Unit/IMAP/ImapFlagTest.php b/tests/Unit/IMAP/ImapFlagTest.php new file mode 100644 index 0000000000..3abc3e0740 --- /dev/null +++ b/tests/Unit/IMAP/ImapFlagTest.php @@ -0,0 +1,46 @@ +imapFlag = new ImapFlag(); + } + + /** + * @dataProvider dataCreate + */ + public function testCreate(string $label, string $expected): void { + $actual = $this->imapFlag->create($label); + $this->assertEquals($expected, $actual); + } + + public function dataCreate(): array { + return [ + 'umlauts and lowercase' => [ + 'Test ÄÖÜ', + '$test_&amqa1gdc-' + ], + 'maximum 63 characters' => [ + '1234567890123456789012345678901234567890123456789012345678901234', + '$123456789012345678901234567890123456789012345678901234567890123', + ], + ]; + } +} diff --git a/tests/Unit/Service/MailManagerTest.php b/tests/Unit/Service/MailManagerTest.php index 2c35f3caa7..7c390d253c 100644 --- a/tests/Unit/Service/MailManagerTest.php +++ b/tests/Unit/Service/MailManagerTest.php @@ -27,6 +27,7 @@ use OCA\Mail\Folder; use OCA\Mail\IMAP\FolderMapper; use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\IMAP\ImapFlag; use OCA\Mail\IMAP\MailboxSync; use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; use OCA\Mail\Service\MailManager; @@ -72,7 +73,7 @@ class MailManagerTest extends TestCase { /** @var ThreadMapper|MockObject */ private $threadMapper; - + protected function setUp(): void { parent::setUp(); @@ -100,7 +101,8 @@ protected function setUp(): void { $this->logger, $this->tagMapper, $this->messageTagsMapper, - $this->threadMapper + $this->threadMapper, + new ImapFlag(), ); } From c0c4fa45be6ca602b5a79e235e2a07313a71025d Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Thu, 12 Sep 2024 21:57:14 +0200 Subject: [PATCH 2/6] feat: add sieve utils Signed-off-by: Daniel Kesselberg --- lib/Sieve/SieveUtils.php | 38 +++++++++++++++++++++ tests/Unit/Sieve/SieveUtilsTest.php | 51 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 lib/Sieve/SieveUtils.php create mode 100644 tests/Unit/Sieve/SieveUtilsTest.php diff --git a/lib/Sieve/SieveUtils.php b/lib/Sieve/SieveUtils.php new file mode 100644 index 0000000000..a6e9b5c652 --- /dev/null +++ b/lib/Sieve/SieveUtils.php @@ -0,0 +1,38 @@ +assertSame($expected, $actual); + } + + public static function providerEscapeString(): array { + return [ + ['foo"bar', 'foo\"bar'], + ['foo\\bar', 'foo\\\\bar'], + ['foo"\\bar', 'foo\"\\\\bar'], + ['foobar', 'foobar'], + ['', ''], + ]; + } + + /** + * @dataProvider providerStringList + */ + public function testStringList(array $values, string $expected): void { + $actual = SieveUtils::stringList($values); + $this->assertSame($expected, $actual); + } + + public static function providerStringList(): array { + return [ + [['Hello', 'World'], '["Hello", "World"]'], + [['foo"bar', 'foo\\bar'], '["foo\"bar", "foo\\\\bar"]'], + [['foo"bar', 'foo\\bar', 'foo"\\bar'], '["foo\"bar", "foo\\\\bar", "foo\"\\\\bar"]'], + [['foobar'], '["foobar"]'], + [[], '[""]'], + ]; + } +} From 9d993e20ff1d31105234eab20aa2c0242ec8fa92 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Wed, 17 Jul 2024 15:49:02 +0200 Subject: [PATCH 3/6] 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 | 67 ++++++ 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 + src/components/mailFilter/Action.vue | 98 ++++++++ src/components/mailFilter/ActionAddflag.vue | 45 ++++ src/components/mailFilter/ActionFileinto.vue | 50 ++++ src/components/mailFilter/ActionStop.vue | 23 ++ src/components/mailFilter/DeleteModal.vue | 60 +++++ src/components/mailFilter/MailFilters.vue | 217 ++++++++++++++++++ src/components/mailFilter/Operator.vue | 48 ++++ src/components/mailFilter/Test.vue | 120 ++++++++++ src/components/mailFilter/UpdateModal.vue | 177 ++++++++++++++ src/service/MailFilterService.js | 22 ++ src/store/mailFilterStore.js | 52 +++++ .../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 + 41 files changed, 1925 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/Action.vue create mode 100644 src/components/mailFilter/ActionAddflag.vue create mode 100644 src/components/mailFilter/ActionFileinto.vue create mode 100644 src/components/mailFilter/ActionStop.vue create mode 100644 src/components/mailFilter/DeleteModal.vue create mode 100644 src/components/mailFilter/MailFilters.vue create mode 100644 src/components/mailFilter/Operator.vue create mode 100644 src/components/mailFilter/Test.vue create mode 100644 src/components/mailFilter/UpdateModal.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..1e2a9364cb 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..6d47c4e342 --- /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..2f52d708de --- /dev/null +++ b/lib/Service/MailFilter/FilterParser.php @@ -0,0 +1,67 @@ +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/Action.vue b/src/components/mailFilter/Action.vue new file mode 100644 index 0000000000..7f5754d780 --- /dev/null +++ b/src/components/mailFilter/Action.vue @@ -0,0 +1,98 @@ + + + + diff --git a/src/components/mailFilter/ActionAddflag.vue b/src/components/mailFilter/ActionAddflag.vue new file mode 100644 index 0000000000..961f512cf4 --- /dev/null +++ b/src/components/mailFilter/ActionAddflag.vue @@ -0,0 +1,45 @@ + + + + diff --git a/src/components/mailFilter/ActionFileinto.vue b/src/components/mailFilter/ActionFileinto.vue new file mode 100644 index 0000000000..4ea61da394 --- /dev/null +++ b/src/components/mailFilter/ActionFileinto.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/components/mailFilter/ActionStop.vue b/src/components/mailFilter/ActionStop.vue new file mode 100644 index 0000000000..6d7184fd6d --- /dev/null +++ b/src/components/mailFilter/ActionStop.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/mailFilter/DeleteModal.vue b/src/components/mailFilter/DeleteModal.vue new file mode 100644 index 0000000000..3948068df3 --- /dev/null +++ b/src/components/mailFilter/DeleteModal.vue @@ -0,0 +1,60 @@ + + + + diff --git a/src/components/mailFilter/MailFilters.vue b/src/components/mailFilter/MailFilters.vue new file mode 100644 index 0000000000..6736fbf50c --- /dev/null +++ b/src/components/mailFilter/MailFilters.vue @@ -0,0 +1,217 @@ + + + + + + diff --git a/src/components/mailFilter/Operator.vue b/src/components/mailFilter/Operator.vue new file mode 100644 index 0000000000..c26293822b --- /dev/null +++ b/src/components/mailFilter/Operator.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/components/mailFilter/Test.vue b/src/components/mailFilter/Test.vue new file mode 100644 index 0000000000..0b03d379a6 --- /dev/null +++ b/src/components/mailFilter/Test.vue @@ -0,0 +1,120 @@ + + + + diff --git a/src/components/mailFilter/UpdateModal.vue b/src/components/mailFilter/UpdateModal.vue new file mode 100644 index 0000000000..b7b34a3686 --- /dev/null +++ b/src/components/mailFilter/UpdateModal.vue @@ -0,0 +1,177 @@ + + + + diff --git a/src/service/MailFilterService.js b/src/service/MailFilterService.js new file mode 100644 index 0000000000..eaabaf1046 --- /dev/null +++ b/src/service/MailFilterService.js @@ -0,0 +1,22 @@ +/** + * 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 +} + +export async function updateFilters(accountId, filters) { + const url = generateUrl('/apps/mail/api/mailfilter/{accountId}', { accountId }) + + const { data } = await axios.put(url, { filters }) + + return data +} diff --git a/src/store/mailFilterStore.js b/src/store/mailFilterStore.js new file mode 100644 index 0000000000..da6d1d6fc2 --- /dev/null +++ b/src/store/mailFilterStore.js @@ -0,0 +1,52 @@ +/** + * 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) + state.filters = filters.map((filter) => { + filter.id = randomId() + filter.tests.map((test) => { + test.id = randomId() + return test + }) + filter.actions.map((action) => { + action.id = randomId() + return action + }) + 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..d20750752a --- /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 ### +# FILTER: [{"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}] +# 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..683739fc83 --- /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 ### +# FILTER: [{"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}] +# 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..71c197d181 --- /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 ### +# FILTER: [{"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}] +# Test 3.1 +if anyof (header :contains "Subject" ["Project-A", "Project-B"], address :is :all "From" ["john@example.org"]) { +fileinto "Test Data"; +stop; +} +# 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..2877d7de01 --- /dev/null +++ b/tests/data/mail-filter/builder4.sieve @@ -0,0 +1,6 @@ +# Hello, this is a test +### Nextcloud Mail: Filters ### DON'T EDIT ### +# FILTER: [{"actions":[{"flag":"Flag 123","type":"addflag"}],"enable":true,"name":"Test 4","operator":"allof","priority":60,"tests":[]}] +# 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..8aad19b928 --- /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 ### +# FILTER: [{"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"]}]}] +# 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..e5a756d66e --- /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 ### +# FILTER: [{"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"]}]}] +# 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..d20750752a --- /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 ### +# FILTER: [{"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}] +# 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..10e91a2543 --- /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 ### +# FILTER: [{"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}] +# 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 ### From d877e5eefa08fd87f7258621e84fa508af202ae2 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Wed, 9 Oct 2024 17:22:54 +0200 Subject: [PATCH 4/6] fixup! feat: mail filters Signed-off-by: Daniel Kesselberg --- src/components/mailFilter/Operator.vue | 2 +- src/components/mailFilter/Test.vue | 2 +- src/components/mailFilter/UpdateModal.vue | 19 +++++++++++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/components/mailFilter/Operator.vue b/src/components/mailFilter/Operator.vue index c26293822b..16ba5639f0 100644 --- a/src/components/mailFilter/Operator.vue +++ b/src/components/mailFilter/Operator.vue @@ -5,7 +5,7 @@