From 3906f272cb7fc2734f1cbf33021dc3478705beba Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Wed, 11 Sep 2024 18:21:12 +0200 Subject: [PATCH 1/3] 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 948698742810bc3802340ff94ba55ec28e2583cb Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Thu, 12 Sep 2024 21:57:14 +0200 Subject: [PATCH 2/3] 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 d74c401e6653be7d0713cb0c6d2ab358c9cf7516 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Thu, 10 Oct 2024 21:58:53 +0200 Subject: [PATCH 3/3] 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 | 62 +++++ 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, 2481 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..72f6dd98c9 --- /dev/null +++ b/src/components/mailFilter/DeleteModal.vue @@ -0,0 +1,62 @@ + + + + + + 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.') }} +