From 0981ed7f649af3eb4dc33487d1bf3470d81a86c7 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Thu, 10 Oct 2024 21:58:53 +0200 Subject: [PATCH] feat: mail filters Co-authored-by: Hamza Mahjoubi Signed-off-by: Daniel Kesselberg --- REUSE.toml | 6 + lib/Controller/FilterController.php | 59 +++++ lib/Exception/FilterParserException.php | 29 +++ lib/Service/AllowedRecipientsService.php | 34 +++ lib/Service/FilterService.php | 88 +++++++ lib/Service/MailFilter/FilterBuilder.php | 165 ++++++++++++ lib/Service/MailFilter/FilterParser.php | 67 +++++ lib/Service/MailFilter/FilterParserResult.php | 43 ++++ lib/Service/MailFilter/FilterState.php | 35 +++ lib/Service/MailManager.php | 6 +- lib/Service/OutOfOffice/OutOfOfficeParser.php | 10 +- lib/Service/OutOfOfficeService.php | 16 +- src/components/AccountSettings.vue | 13 +- src/components/SieveAccountForm.vue | 2 +- 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 | 218 ++++++++++++++++ src/components/mailFilter/Operator.vue | 48 ++++ src/components/mailFilter/Test.vue | 120 +++++++++ src/components/mailFilter/UpdateModal.vue | 213 ++++++++++++++++ src/service/MailFilterService.js | 22 ++ src/store/mailFilterStore.js | 52 ++++ .../Service/AllowedRecipientsServiceTest.php | 54 ++++ tests/Unit/Service/FilterServiceTest.php | 239 ++++++++++++++++++ .../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/builder7.json | 1 + tests/data/mail-filter/builder7.sieve | 1 + tests/data/mail-filter/parser1.sieve | 11 + tests/data/mail-filter/parser2.sieve | 11 + tests/data/mail-filter/parser3.sieve | 21 ++ .../data/mail-filter/parser3_untouched.sieve | 21 ++ tests/data/mail-filter/parser4.sieve | 31 +++ .../data/mail-filter/parser4_untouched.sieve | 21 ++ tests/data/mail-filter/service1.json | 23 ++ tests/data/mail-filter/service1.sieve | 21 ++ tests/data/mail-filter/service1_new.sieve | 31 +++ tests/data/mail-filter/service2.json | 23 ++ tests/data/mail-filter/service2.sieve | 35 +++ tests/data/mail-filter/service2_new.sieve | 31 +++ 56 files changed, 2479 insertions(+), 36 deletions(-) create mode 100644 lib/Controller/FilterController.php create mode 100644 lib/Exception/FilterParserException.php create mode 100644 lib/Service/AllowedRecipientsService.php create mode 100644 lib/Service/FilterService.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 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/FilterServiceTest.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/builder7.json create mode 100644 tests/data/mail-filter/builder7.sieve create mode 100644 tests/data/mail-filter/parser1.sieve create mode 100644 tests/data/mail-filter/parser2.sieve create mode 100644 tests/data/mail-filter/parser3.sieve create mode 100644 tests/data/mail-filter/parser3_untouched.sieve create mode 100644 tests/data/mail-filter/parser4.sieve create mode 100644 tests/data/mail-filter/parser4_untouched.sieve create mode 100644 tests/data/mail-filter/service1.json create mode 100644 tests/data/mail-filter/service1.sieve create mode 100644 tests/data/mail-filter/service1_new.sieve create mode 100644 tests/data/mail-filter/service2.json create mode 100644 tests/data/mail-filter/service2.sieve create mode 100644 tests/data/mail-filter/service2_new.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/FilterController.php b/lib/Controller/FilterController.php new file mode 100644 index 0000000000..f2fc3104e8 --- /dev/null +++ b/lib/Controller/FilterController.php @@ -0,0 +1,59 @@ +currentUserId = $userId; + } + + #[Route(Route::TYPE_FRONTPAGE, verb: 'GET', url: '/api/filter/{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/filter/{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/FilterService.php b/lib/Service/FilterService.php new file mode 100644 index 0000000000..1ddb5b27ab --- /dev/null +++ b/lib/Service/FilterService.php @@ -0,0 +1,88 @@ +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 instanceof OutOfOfficeState) { + $newScript = $this->outOfOfficeParser->buildSieveScript( + $oooState, + $newScript, + $this->allowedRecipientsService->get($account), + ); + } + + try { + $this->sieveService->updateActiveScript($account->getUserId(), $account->getId(), $newScript); + } catch (ManageSieveException $e) { + $this->logger->error('Failed to save sieve script: ' . $e->getMessage(), [ + 'exception' => $e, + 'script' => $newScript, + ]); + throw $e; + } + } +} diff --git a/lib/Service/MailFilter/FilterBuilder.php b/lib/Service/MailFilter/FilterBuilder.php new file mode 100644 index 0000000000..7c8e6cd8d5 --- /dev/null +++ b/lib/Service/MailFilter/FilterBuilder.php @@ -0,0 +1,165 @@ +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]; + } + + $actions = array_map( + static fn ($action) => "\t" . $action, + $actions + ); + + $ifBlock = sprintf( + "if %s {\r\n%s\r\n}", + $ifTest, + implode(self::SIEVE_NEWLINE, $actions) + ); + + $commands[] = $ifBlock; + } + + $lines = []; + + $extensions = array_unique($extensions); + if (count($extensions) > 0) { + $lines[] = self::SEPARATOR; + $lines[] = 'require ' . SieveUtils::stringList($extensions) . ';'; + $lines[] = self::SEPARATOR; + } + + /* + * Using implode("\r\n", $lines) may introduce an extra newline if the original script already ends with one. + * There may be a cleaner solution, but I couldn't find one that works seamlessly with Filter and Autoresponder. + * Feel free to give it a try! + */ + if (str_ends_with($untouchedScript, self::SIEVE_NEWLINE . self::SIEVE_NEWLINE)) { + $untouchedScript = substr($untouchedScript, 0, -2); + } + $lines[] = $untouchedScript; + + if (count($filters) > 0) { + $lines[] = self::SEPARATOR; + $lines[] = self::DATA_MARKER . json_encode($this->sanitizeDefinition($filters), JSON_THROW_ON_ERROR); + array_push($lines, ...$commands); + $lines[] = self::SEPARATOR; + } + + return implode(self::SIEVE_NEWLINE, $lines); + } + + 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/MailManager.php b/lib/Service/MailManager.php index 904f54786b..7fd3cf7827 100644 --- a/lib/Service/MailManager.php +++ b/lib/Service/MailManager.php @@ -94,7 +94,8 @@ class MailManager implements IMailManager { /** @var ThreadMapper */ private $threadMapper; - public function __construct(IMAPClientFactory $imapClientFactory, + public function __construct( + IMAPClientFactory $imapClientFactory, MailboxMapper $mailboxMapper, MailboxSync $mailboxSync, FolderMapper $folderMapper, @@ -105,7 +106,8 @@ public function __construct(IMAPClientFactory $imapClientFactory, TagMapper $tagMapper, MessageTagsMapper $messageTagsMapper, ThreadMapper $threadMapper, - private ImapFlag $imapFlag) { + private ImapFlag $imapFlag, + ) { $this->imapClientFactory = $imapClientFactory; $this->mailboxMapper = $mailboxMapper; $this->mailboxSync = $mailboxSync; 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 d10f323734..6ff7e760ad 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; @@ -33,8 +32,8 @@ public function __construct( private OutOfOfficeParser $outOfOfficeParser, private SieveService $sieveService, private LoggerInterface $logger, - private AliasesService $aliasesService, private ITimeFactory $timeFactory, + private AllowedRecipientsService $allowedRecipientsService, private IAvailabilityCoordinator $availabilityCoordinator, ) { } @@ -63,7 +62,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); @@ -142,15 +141,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..92f106a71d 100644 --- a/src/components/AccountSettings.vue +++ b/src/components/AccountSettings.vue @@ -55,9 +55,16 @@ {{ t('mail', 'Please connect to a sieve server first.') }}
- {{ t('mail', 'Sieve is a powerful language for writing filters for your mailbox. You can manage the sieve scripts in Mail if your email service supports it.') }} + {{ t('mail', 'Sieve is a powerful language for writing filters for your mailbox. You can manage the sieve scripts in Mail if your email service supports it. Sieve is also required to use Autoresponder and Filters.') }}
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 @@ + + + + + + + + + + + + {{ t('mail', 'Delete action') }} + + + + + + + + + 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 @@ + + + {{ t('mail', 'The stop action ends all processing') }} + + 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..8a1a3a6621 --- /dev/null +++ b/src/components/mailFilter/MailFilters.vue @@ -0,0 +1,218 @@ + + + + {{ t('mail', 'Take control of your email chaos. Filters help you to prioritize what matters and eliminate clutter.') }} + + + + {{ t('mail', 'Filter is active') }} + {{ t('mail', 'Filter is not active') }} + + + + + + + {{ t('mail', 'Delete filter') }} + + + + + + {{ t('mail', 'New filter') }} + + + + + + + + + diff --git a/src/components/mailFilter/Operator.vue b/src/components/mailFilter/Operator.vue new file mode 100644 index 0000000000..16ba5639f0 --- /dev/null +++ b/src/components/mailFilter/Operator.vue @@ -0,0 +1,48 @@ + + + + + {{ t('mail', 'Operator') }} + + + + allof ({{ t('mail', 'If all tests pass, then the actions will be executed') }}) + + + anyof ({{ t('mail', 'If one test pass, then the actions will be executed') }}) + + + + + diff --git a/src/components/mailFilter/Test.vue b/src/components/mailFilter/Test.vue new file mode 100644 index 0000000000..2bc4b05e84 --- /dev/null +++ b/src/components/mailFilter/Test.vue @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + {{ t('mail', 'Delete test') }} + + + + + + + + + + + diff --git a/src/components/mailFilter/UpdateModal.vue b/src/components/mailFilter/UpdateModal.vue new file mode 100644 index 0000000000..e14e56a297 --- /dev/null +++ b/src/components/mailFilter/UpdateModal.vue @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + {{ t('mail', 'Tests') }} + + + + {{ t('mail', 'Tests are applied to incoming emails on your mail server, targeting fields such as subject (the email\'s subject line), from (the sender), and to (the recipient). You can use the following operators to define conditions for these fields:') }} + + + is: {{ t('mail', 'An exact match. The field must be identical to the provided value.') }} + + + contains: {{ t('mail', 'A substring match. The field matches if the provided value is contained within it. For example, "report" would match "port".') }} + + + matches: {{ t('mail', 'A pattern match using wildcards. The "*" symbol represents any number of characters (including none), while "?" represents exactly one character. For example, "*report*" would match "Business report 2024".') }} + + + + + + {{ t('mail', 'New test') }} + + + + + {{ t('mail', 'Actions') }} + + + + {{ t('mail', 'Actions are triggered when the specified tests are true. The following actions are available:') }} + + + fileinto: {{ t('mail', 'Moves the message into a specified folder.') }} + + + addflag: {{ t('mail', 'Adds a flag to the message.') }} + + + stop: {{ t('mail', 'Halts the execution of the filter script. No further filters with will be processed after this action.') }} + + + + + + {{ t('mail', 'New action') }} + + + + + + + {{ t('mail', 'Enable filter') }} + + + + + + + + {{ t('mail', 'Save filter') }} + + + + + + diff --git a/src/service/MailFilterService.js b/src/service/MailFilterService.js new file mode 100644 index 0000000000..4c500295a2 --- /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/filter/{accountId}', { accountId }) + + const { data } = await axios.get(url) + + return data +} + +export async function updateFilters(accountId, filters) { + const url = generateUrl('/apps/mail/api/filter/{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/FilterServiceTest.php b/tests/Unit/Service/FilterServiceTest.php new file mode 100644 index 0000000000..783666ff06 --- /dev/null +++ b/tests/Unit/Service/FilterServiceTest.php @@ -0,0 +1,239 @@ +testFolder = __DIR__ . '/../../data/mail-filter/'; + } + + protected function setUp(): void { + parent::setUp(); + + $this->allowedRecipientsService = $this->createMock(AllowedRecipientsService::class); + $this->outOfOfficeParser = new OutOfOfficeParser(); + $this->filterParser = new FilterParser(); + $this->filterBuilder = new FilterBuilder(new ImapFlag()); + $this->sieveService = $this->createMock(SieveService::class); + $this->logger = new TestLogger(); + + $this->filterService = new FilterService( + $this->allowedRecipientsService, + $this->outOfOfficeParser, + $this->filterParser, + $this->filterBuilder, + $this->sieveService, + $this->logger + ); + } + + public function testParse1(): void { + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setUserId('alice'); + $mailAccount->setEmail('alice@mail.internal'); + + $script = new NamedSieveScript( + 'test.sieve', + file_get_contents($this->testFolder . 'parser1.sieve'), + ); + + $this->sieveService->method('getActiveScript') + ->willReturn($script); + + $result = $this->filterService->parse($mailAccount); + + // Not checking the filters because FilterParserTest.testParser1 uses the same script. + $this->assertCount(1, $result->getFilters()); + + $this->assertEquals("# Hello, this is a test\r\n", $result->getUntouchedSieveScript()); + } + + public function testParse2(): void { + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setUserId('alice'); + $mailAccount->setEmail('alice@mail.internal'); + + $script = new NamedSieveScript( + 'test.sieve', + file_get_contents($this->testFolder . 'parser2.sieve'), + ); + + $this->sieveService->method('getActiveScript') + ->willReturn($script); + + $result = $this->filterService->parse($mailAccount); + + // Not checking the filters because FilterParserTest.testParser2 uses the same script. + $this->assertCount(1, $result->getFilters()); + + $this->assertEquals("# Hello, this is a test\r\n", $result->getUntouchedSieveScript()); + } + + public function testParse3(): void { + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setUserId('alice'); + $mailAccount->setEmail('alice@mail.internal'); + + $script = new NamedSieveScript( + 'test.sieve', + file_get_contents($this->testFolder . 'parser3.sieve'), + ); + + $untouchedScript = file_get_contents($this->testFolder . 'parser3_untouched.sieve'); + + $this->sieveService->method('getActiveScript') + ->willReturn($script); + + $result = $this->filterService->parse($mailAccount); + + $this->assertCount(0, $result->getFilters()); + + $this->assertEquals($untouchedScript, $result->getUntouchedSieveScript()); + } + + public function testParse4(): void { + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setUserId('alice'); + $mailAccount->setEmail('alice@mail.internal'); + + $script = new NamedSieveScript( + 'test.sieve', + file_get_contents($this->testFolder . 'parser4.sieve'), + ); + + $untouchedScript = file_get_contents($this->testFolder . 'parser4_untouched.sieve'); + + $this->sieveService->method('getActiveScript') + ->willReturn($script); + + $result = $this->filterService->parse($mailAccount); + + $filters = $result->getFilters(); + + $this->assertCount(1, $filters); + $this->assertSame('Marketing', $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(['marketing@mail.internal'], $filters[0]['tests'][0]['values']); + + $this->assertCount(1, $filters[0]['actions']); + $this->assertSame('fileinto', $filters[0]['actions'][0]['type']); + $this->assertSame('Marketing', $filters[0]['actions'][0]['mailbox']); + + $this->assertEquals($untouchedScript, $result->getUntouchedSieveScript()); + } + + /** + * Test case: Add a filter set to a sieve script with autoresponder. + */ + public function testUpdate1(): void { + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setUserId('alice'); + $mailAccount->setEmail('alice@mail.internal'); + + $script = new NamedSieveScript( + 'test.sieve', + file_get_contents($this->testFolder . 'service1.sieve'), + ); + + $filters = json_decode( + file_get_contents($this->testFolder . 'service1.json'), + true + ); + + $this->sieveService->method('getActiveScript') + ->willReturn($script); + + $this->sieveService->method('updateActiveScript') + ->willReturnCallback(function (string $userId, int $accountId, string $script) { + // the .sieve files have \r\n line endings + $script .= "\r\n"; + + $this->assertStringEqualsFile($this->testFolder . 'service1_new.sieve', $script); + }); + + $this->allowedRecipientsService->method('get') + ->willReturn(['alice@mail.internal']); + + $this->filterService->update($mailAccount, $filters); + } + + /** + * Test case: Delete a filter rule from a set. + */ + public function testUpdate2(): void { + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setUserId('alice'); + $mailAccount->setEmail('alice@mail.internal'); + + $script = new NamedSieveScript( + 'test.sieve', + file_get_contents($this->testFolder . 'service2.sieve'), + ); + + $filters = json_decode( + file_get_contents($this->testFolder . 'service2.json'), + true + ); + + $this->sieveService->method('getActiveScript') + ->willReturn($script); + + $this->sieveService->method('updateActiveScript') + ->willReturnCallback(function (string $userId, int $accountId, string $script) { + // the .sieve files have \r\n line endings + $script .= "\r\n"; + + $this->assertStringEqualsFile($this->testFolder . 'service2_new.sieve', $script); + }); + + $this->allowedRecipientsService->method('get') + ->willReturn(['alice@mail.internal']); + + $this->filterService->update($mailAccount, $filters); + } +} diff --git a/tests/Unit/Service/MailFilter/FilterBuilderTest.php b/tests/Unit/Service/MailFilter/FilterBuilderTest.php new file mode 100644 index 0000000000..c9368be7a1 --- /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 8057bf07f1..522b111077 100644 --- a/tests/Unit/Service/OutOfOfficeServiceTest.php +++ b/tests/Unit/Service/OutOfOfficeServiceTest.php @@ -124,11 +124,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'); @@ -203,11 +203,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..7f96bd0892 --- /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..193af1daa2 --- /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..888d3aa827 --- /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..945164f905 --- /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..8b6db962a6 --- /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/builder7.json b/tests/data/mail-filter/builder7.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/tests/data/mail-filter/builder7.json @@ -0,0 +1 @@ +[] diff --git a/tests/data/mail-filter/builder7.sieve b/tests/data/mail-filter/builder7.sieve new file mode 100644 index 0000000000..6c2dbd6629 --- /dev/null +++ b/tests/data/mail-filter/builder7.sieve @@ -0,0 +1 @@ +# Hello, this is a test 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 ### diff --git a/tests/data/mail-filter/parser3.sieve b/tests/data/mail-filter/parser3.sieve new file mode 100644 index 0000000000..2d4aa8cfcf --- /dev/null +++ b/tests/data/mail-filter/parser3.sieve @@ -0,0 +1,21 @@ +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +require "date"; +require "relational"; +require "vacation"; +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### + +require "fileinto"; + +if allof( + address "From" "noreply@help.nextcloud.org", + header :contains "Subject" "[Nextcloud community]" +){ + fileinto "Community"; +} + +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +# DATA: {"version":1,"enabled":true,"start":"2022-09-02T00:00:00+01:00","end":"2022-09-08T23:59:00+01:00","subject":"On vacation","message":"I'm on vacation."} +if allof(currentdate :value "ge" "iso8601" "2022-09-01T23:00:00Z", currentdate :value "le" "iso8601" "2022-09-08T22:59:00Z") { + vacation :days 4 :subject "On vacation" :addresses ["Test Test ", "Test Alias "] "I'm on vacation."; +} +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### diff --git a/tests/data/mail-filter/parser3_untouched.sieve b/tests/data/mail-filter/parser3_untouched.sieve new file mode 100644 index 0000000000..2d4aa8cfcf --- /dev/null +++ b/tests/data/mail-filter/parser3_untouched.sieve @@ -0,0 +1,21 @@ +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +require "date"; +require "relational"; +require "vacation"; +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### + +require "fileinto"; + +if allof( + address "From" "noreply@help.nextcloud.org", + header :contains "Subject" "[Nextcloud community]" +){ + fileinto "Community"; +} + +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +# DATA: {"version":1,"enabled":true,"start":"2022-09-02T00:00:00+01:00","end":"2022-09-08T23:59:00+01:00","subject":"On vacation","message":"I'm on vacation."} +if allof(currentdate :value "ge" "iso8601" "2022-09-01T23:00:00Z", currentdate :value "le" "iso8601" "2022-09-08T22:59:00Z") { + vacation :days 4 :subject "On vacation" :addresses ["Test Test ", "Test Alias "] "I'm on vacation."; +} +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### diff --git a/tests/data/mail-filter/parser4.sieve b/tests/data/mail-filter/parser4.sieve new file mode 100644 index 0000000000..9a55e7cdd7 --- /dev/null +++ b/tests/data/mail-filter/parser4.sieve @@ -0,0 +1,31 @@ +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +require "date"; +require "relational"; +require "vacation"; +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +### Nextcloud Mail: Filters ### DON'T EDIT ### +require ["fileinto"]; +### Nextcloud Mail: Filters ### DON'T EDIT ### + +require "fileinto"; + +if allof( + address "From" "noreply@help.nextcloud.org", + header :contains "Subject" "[Nextcloud community]" +){ + fileinto "Community"; +} + +### Nextcloud Mail: Filters ### DON'T EDIT ### +# FILTER: [{"name":"Marketing","enable":true,"operator":"allof","tests":[{"operator":"is","values":["marketing@mail.internal"],"field":"from"}],"actions":[{"type":"fileinto","mailbox":"Marketing"}],"priority":10}] +# Marketing +if address :is :all "From" ["marketing@mail.internal"] { + fileinto "Marketing"; +} +### Nextcloud Mail: Filters ### DON'T EDIT ### +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +# DATA: {"version":1,"enabled":true,"start":"2024-10-08T22:00:00+00:00","subject":"Thanks for your message!","message":"I'm not here, please try again later.\u00a0"} +if currentdate :value "ge" "iso8601" "2024-10-08T22:00:00Z" { + vacation :days 4 :subject "Thanks for your message!" :addresses ["alice@mail.internal"] "I'm not here, please try again later. "; +} +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### diff --git a/tests/data/mail-filter/parser4_untouched.sieve b/tests/data/mail-filter/parser4_untouched.sieve new file mode 100644 index 0000000000..7bb0f893c5 --- /dev/null +++ b/tests/data/mail-filter/parser4_untouched.sieve @@ -0,0 +1,21 @@ +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +require "date"; +require "relational"; +require "vacation"; +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### + +require "fileinto"; + +if allof( + address "From" "noreply@help.nextcloud.org", + header :contains "Subject" "[Nextcloud community]" +){ + fileinto "Community"; +} + +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +# DATA: {"version":1,"enabled":true,"start":"2024-10-08T22:00:00+00:00","subject":"Thanks for your message!","message":"I'm not here, please try again later.\u00a0"} +if currentdate :value "ge" "iso8601" "2024-10-08T22:00:00Z" { + vacation :days 4 :subject "Thanks for your message!" :addresses ["alice@mail.internal"] "I'm not here, please try again later. "; +} +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### diff --git a/tests/data/mail-filter/service1.json b/tests/data/mail-filter/service1.json new file mode 100644 index 0000000000..fef65236f3 --- /dev/null +++ b/tests/data/mail-filter/service1.json @@ -0,0 +1,23 @@ +[ + { + "name": "Marketing", + "enable": true, + "operator": "allof", + "tests": [ + { + "operator": "is", + "values": [ + "marketing@mail.internal" + ], + "field": "from" + } + ], + "actions": [ + { + "type": "fileinto", + "mailbox": "Marketing" + } + ], + "priority": 10 + } +] diff --git a/tests/data/mail-filter/service1.sieve b/tests/data/mail-filter/service1.sieve new file mode 100644 index 0000000000..7bb0f893c5 --- /dev/null +++ b/tests/data/mail-filter/service1.sieve @@ -0,0 +1,21 @@ +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +require "date"; +require "relational"; +require "vacation"; +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### + +require "fileinto"; + +if allof( + address "From" "noreply@help.nextcloud.org", + header :contains "Subject" "[Nextcloud community]" +){ + fileinto "Community"; +} + +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +# DATA: {"version":1,"enabled":true,"start":"2024-10-08T22:00:00+00:00","subject":"Thanks for your message!","message":"I'm not here, please try again later.\u00a0"} +if currentdate :value "ge" "iso8601" "2024-10-08T22:00:00Z" { + vacation :days 4 :subject "Thanks for your message!" :addresses ["alice@mail.internal"] "I'm not here, please try again later. "; +} +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### diff --git a/tests/data/mail-filter/service1_new.sieve b/tests/data/mail-filter/service1_new.sieve new file mode 100644 index 0000000000..9a55e7cdd7 --- /dev/null +++ b/tests/data/mail-filter/service1_new.sieve @@ -0,0 +1,31 @@ +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +require "date"; +require "relational"; +require "vacation"; +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +### Nextcloud Mail: Filters ### DON'T EDIT ### +require ["fileinto"]; +### Nextcloud Mail: Filters ### DON'T EDIT ### + +require "fileinto"; + +if allof( + address "From" "noreply@help.nextcloud.org", + header :contains "Subject" "[Nextcloud community]" +){ + fileinto "Community"; +} + +### Nextcloud Mail: Filters ### DON'T EDIT ### +# FILTER: [{"name":"Marketing","enable":true,"operator":"allof","tests":[{"operator":"is","values":["marketing@mail.internal"],"field":"from"}],"actions":[{"type":"fileinto","mailbox":"Marketing"}],"priority":10}] +# Marketing +if address :is :all "From" ["marketing@mail.internal"] { + fileinto "Marketing"; +} +### Nextcloud Mail: Filters ### DON'T EDIT ### +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +# DATA: {"version":1,"enabled":true,"start":"2024-10-08T22:00:00+00:00","subject":"Thanks for your message!","message":"I'm not here, please try again later.\u00a0"} +if currentdate :value "ge" "iso8601" "2024-10-08T22:00:00Z" { + vacation :days 4 :subject "Thanks for your message!" :addresses ["alice@mail.internal"] "I'm not here, please try again later. "; +} +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### diff --git a/tests/data/mail-filter/service2.json b/tests/data/mail-filter/service2.json new file mode 100644 index 0000000000..6783e49cf4 --- /dev/null +++ b/tests/data/mail-filter/service2.json @@ -0,0 +1,23 @@ +[ + { + "name": "Add flag for emails with subject Hello", + "enable": true, + "operator": "allof", + "tests": [ + { + "operator": "contains", + "values": [ + "Hello" + ], + "field": "subject" + } + ], + "actions": [ + { + "type": "addflag", + "flag": "Test 123" + } + ], + "priority": 10 + } +] diff --git a/tests/data/mail-filter/service2.sieve b/tests/data/mail-filter/service2.sieve new file mode 100644 index 0000000000..d605d1e4ea --- /dev/null +++ b/tests/data/mail-filter/service2.sieve @@ -0,0 +1,35 @@ +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +require "date"; +require "relational"; +require "vacation"; +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +### Nextcloud Mail: Filters ### DON'T EDIT ### +require ["imap4flags"]; +### Nextcloud Mail: Filters ### DON'T EDIT ### + +require "fileinto"; + +if allof( + address "From" "noreply@help.nextcloud.org", + header :contains "Subject" "[Nextcloud community]" +){ + fileinto "Community"; +} + +### Nextcloud Mail: Filters ### DON'T EDIT ### +# FILTER: [{"name":"Add flag for emails with subject Hello","enable":true,"operator":"allof","tests":[{"operator":"contains","values":["Hello"],"field":"subject"}],"actions":[{"type":"addflag","flag":"Test 123"}],"priority":10},{"name":"Add flag for emails with subject World","enable":true,"operator":"allof","tests":[{"operator":"contains","values":["World"],"field":"subject"}],"actions":[{"type":"addflag","flag":"Test 456"}],"priority":20}] +# Add flag for emails with subject Hello +if header :contains "Subject" ["Hello"] { + addflag "$test_123"; +} +# Add flag for emails with subject World +if header :contains "Subject" ["World"] { + addflag "$test_456"; +} +### Nextcloud Mail: Filters ### DON'T EDIT ### +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +# DATA: {"version":1,"enabled":true,"start":"2024-10-08T22:00:00+00:00","subject":"Thanks for your message!","message":"I'm not here, please try again later.\u00a0"} +if currentdate :value "ge" "iso8601" "2024-10-08T22:00:00Z" { + vacation :days 4 :subject "Thanks for your message!" :addresses ["alice@mail.internal"] "I'm not here, please try again later. "; +} +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### diff --git a/tests/data/mail-filter/service2_new.sieve b/tests/data/mail-filter/service2_new.sieve new file mode 100644 index 0000000000..3b9e21eee8 --- /dev/null +++ b/tests/data/mail-filter/service2_new.sieve @@ -0,0 +1,31 @@ +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +require "date"; +require "relational"; +require "vacation"; +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +### Nextcloud Mail: Filters ### DON'T EDIT ### +require ["imap4flags"]; +### Nextcloud Mail: Filters ### DON'T EDIT ### + +require "fileinto"; + +if allof( + address "From" "noreply@help.nextcloud.org", + header :contains "Subject" "[Nextcloud community]" +){ + fileinto "Community"; +} + +### Nextcloud Mail: Filters ### DON'T EDIT ### +# FILTER: [{"name":"Add flag for emails with subject Hello","enable":true,"operator":"allof","tests":[{"operator":"contains","values":["Hello"],"field":"subject"}],"actions":[{"type":"addflag","flag":"Test 123"}],"priority":10}] +# Add flag for emails with subject Hello +if header :contains "Subject" ["Hello"] { + addflag "$test_123"; +} +### Nextcloud Mail: Filters ### DON'T EDIT ### +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ### +# DATA: {"version":1,"enabled":true,"start":"2024-10-08T22:00:00+00:00","subject":"Thanks for your message!","message":"I'm not here, please try again later.\u00a0"} +if currentdate :value "ge" "iso8601" "2024-10-08T22:00:00Z" { + vacation :days 4 :subject "Thanks for your message!" :addresses ["alice@mail.internal"] "I'm not here, please try again later. "; +} +### Nextcloud Mail: Vacation Responder ### DON'T EDIT ###
{{ t('mail', 'The stop action ends all processing') }}
{{ t('mail', 'Take control of your email chaos. Filters help you to prioritize what matters and eliminate clutter.') }}
+ {{ t('mail', 'Tests are applied to incoming emails on your mail server, targeting fields such as subject (the email\'s subject line), from (the sender), and to (the recipient). You can use the following operators to define conditions for these fields:') }} +
+ is: {{ t('mail', 'An exact match. The field must be identical to the provided value.') }} +
+ contains: {{ t('mail', 'A substring match. The field matches if the provided value is contained within it. For example, "report" would match "port".') }} +
+ matches: {{ t('mail', 'A pattern match using wildcards. The "*" symbol represents any number of characters (including none), while "?" represents exactly one character. For example, "*report*" would match "Business report 2024".') }} +
+ {{ t('mail', 'Actions are triggered when the specified tests are true. The following actions are available:') }} +
+ fileinto: {{ t('mail', 'Moves the message into a specified folder.') }} +
+ addflag: {{ t('mail', 'Adds a flag to the message.') }} +
+ stop: {{ t('mail', 'Halts the execution of the filter script. No further filters with will be processed after this action.') }} +