From df990d772d9a9f26cce4e4ced74a7b1d3e439369 Mon Sep 17 00:00:00 2001
From: Daniel Kesselberg
Date: Wed, 11 Sep 2024 18:21:12 +0200
Subject: [PATCH 1/6] refactor: move imap flag encoding to helper
Signed-off-by: Daniel Kesselberg
---
lib/Exception/ImapFlagEncodingException.php | 21 ++++++++++
lib/IMAP/ImapFlag.php | 28 +++++++++++++
lib/Service/MailManager.php | 14 ++++---
tests/Unit/IMAP/ImapFlagTest.php | 46 +++++++++++++++++++++
tests/Unit/Service/MailManagerTest.php | 6 ++-
5 files changed, 107 insertions(+), 8 deletions(-)
create mode 100644 lib/Exception/ImapFlagEncodingException.php
create mode 100644 lib/IMAP/ImapFlag.php
create mode 100644 tests/Unit/IMAP/ImapFlagTest.php
diff --git a/lib/Exception/ImapFlagEncodingException.php b/lib/Exception/ImapFlagEncodingException.php
new file mode 100644
index 0000000000..5fd2e08bf0
--- /dev/null
+++ b/lib/Exception/ImapFlagEncodingException.php
@@ -0,0 +1,21 @@
+imapClientFactory = $imapClientFactory;
$this->mailboxMapper = $mailboxMapper;
$this->mailboxSync = $mailboxSync;
@@ -795,12 +798,11 @@ public function isPermflagsEnabled(Horde_Imap_Client_Socket $client, Account $ac
}
public function createTag(string $displayName, string $color, string $userId): Tag {
- $imapLabel = str_replace(' ', '_', $displayName);
- $imapLabel = mb_convert_encoding($imapLabel, 'UTF7-IMAP', 'UTF-8');
- if ($imapLabel === false) {
- throw new ClientException('Error converting display name to UTF7-IMAP ', 0);
+ try {
+ $imapLabel = $this->imapFlag->create($displayName);
+ } catch (ImapFlagEncodingException $e) {
+ throw new ClientException('Error converting display name to UTF7-IMAP ', 0, $e);
}
- $imapLabel = '$' . strtolower(mb_strcut($imapLabel, 0, 63));
try {
return $this->getTagByImapLabel($imapLabel, $userId);
diff --git a/tests/Unit/IMAP/ImapFlagTest.php b/tests/Unit/IMAP/ImapFlagTest.php
new file mode 100644
index 0000000000..3abc3e0740
--- /dev/null
+++ b/tests/Unit/IMAP/ImapFlagTest.php
@@ -0,0 +1,46 @@
+imapFlag = new ImapFlag();
+ }
+
+ /**
+ * @dataProvider dataCreate
+ */
+ public function testCreate(string $label, string $expected): void {
+ $actual = $this->imapFlag->create($label);
+ $this->assertEquals($expected, $actual);
+ }
+
+ public function dataCreate(): array {
+ return [
+ 'umlauts and lowercase' => [
+ 'Test ÄÖÜ',
+ '$test_&amqa1gdc-'
+ ],
+ 'maximum 63 characters' => [
+ '1234567890123456789012345678901234567890123456789012345678901234',
+ '$123456789012345678901234567890123456789012345678901234567890123',
+ ],
+ ];
+ }
+}
diff --git a/tests/Unit/Service/MailManagerTest.php b/tests/Unit/Service/MailManagerTest.php
index 2c35f3caa7..7c390d253c 100644
--- a/tests/Unit/Service/MailManagerTest.php
+++ b/tests/Unit/Service/MailManagerTest.php
@@ -27,6 +27,7 @@
use OCA\Mail\Folder;
use OCA\Mail\IMAP\FolderMapper;
use OCA\Mail\IMAP\IMAPClientFactory;
+use OCA\Mail\IMAP\ImapFlag;
use OCA\Mail\IMAP\MailboxSync;
use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper;
use OCA\Mail\Service\MailManager;
@@ -72,7 +73,7 @@ class MailManagerTest extends TestCase {
/** @var ThreadMapper|MockObject */
private $threadMapper;
-
+
protected function setUp(): void {
parent::setUp();
@@ -100,7 +101,8 @@ protected function setUp(): void {
$this->logger,
$this->tagMapper,
$this->messageTagsMapper,
- $this->threadMapper
+ $this->threadMapper,
+ new ImapFlag(),
);
}
From c0c4fa45be6ca602b5a79e235e2a07313a71025d Mon Sep 17 00:00:00 2001
From: Daniel Kesselberg
Date: Thu, 12 Sep 2024 21:57:14 +0200
Subject: [PATCH 2/6] feat: add sieve utils
Signed-off-by: Daniel Kesselberg
---
lib/Sieve/SieveUtils.php | 38 +++++++++++++++++++++
tests/Unit/Sieve/SieveUtilsTest.php | 51 +++++++++++++++++++++++++++++
2 files changed, 89 insertions(+)
create mode 100644 lib/Sieve/SieveUtils.php
create mode 100644 tests/Unit/Sieve/SieveUtilsTest.php
diff --git a/lib/Sieve/SieveUtils.php b/lib/Sieve/SieveUtils.php
new file mode 100644
index 0000000000..a6e9b5c652
--- /dev/null
+++ b/lib/Sieve/SieveUtils.php
@@ -0,0 +1,38 @@
+assertSame($expected, $actual);
+ }
+
+ public static function providerEscapeString(): array {
+ return [
+ ['foo"bar', 'foo\"bar'],
+ ['foo\\bar', 'foo\\\\bar'],
+ ['foo"\\bar', 'foo\"\\\\bar'],
+ ['foobar', 'foobar'],
+ ['', ''],
+ ];
+ }
+
+ /**
+ * @dataProvider providerStringList
+ */
+ public function testStringList(array $values, string $expected): void {
+ $actual = SieveUtils::stringList($values);
+ $this->assertSame($expected, $actual);
+ }
+
+ public static function providerStringList(): array {
+ return [
+ [['Hello', 'World'], '["Hello", "World"]'],
+ [['foo"bar', 'foo\\bar'], '["foo\"bar", "foo\\\\bar"]'],
+ [['foo"bar', 'foo\\bar', 'foo"\\bar'], '["foo\"bar", "foo\\\\bar", "foo\"\\\\bar"]'],
+ [['foobar'], '["foobar"]'],
+ [[], '[""]'],
+ ];
+ }
+}
From 9d993e20ff1d31105234eab20aa2c0242ec8fa92 Mon Sep 17 00:00:00 2001
From: Daniel Kesselberg
Date: Wed, 17 Jul 2024 15:49:02 +0200
Subject: [PATCH 3/6] feat: mail filters
Co-authored-by: Hamza Mahjoubi
Signed-off-by: Daniel Kesselberg
---
REUSE.toml | 6 +
lib/Controller/MailfilterController.php | 58 +++++
lib/Exception/FilterParserException.php | 29 +++
lib/Service/AllowedRecipientsService.php | 34 +++
lib/Service/MailFilter/FilterBuilder.php | 154 +++++++++++++
lib/Service/MailFilter/FilterParser.php | 67 ++++++
lib/Service/MailFilter/FilterParserResult.php | 43 ++++
lib/Service/MailFilter/FilterState.php | 35 +++
lib/Service/MailFilterService.php | 89 +++++++
lib/Service/OutOfOffice/OutOfOfficeParser.php | 10 +-
lib/Service/OutOfOfficeService.php | 16 +-
src/components/AccountSettings.vue | 9 +
src/components/mailFilter/Action.vue | 98 ++++++++
src/components/mailFilter/ActionAddflag.vue | 45 ++++
src/components/mailFilter/ActionFileinto.vue | 50 ++++
src/components/mailFilter/ActionStop.vue | 23 ++
src/components/mailFilter/DeleteModal.vue | 60 +++++
src/components/mailFilter/MailFilters.vue | 217 ++++++++++++++++++
src/components/mailFilter/Operator.vue | 48 ++++
src/components/mailFilter/Test.vue | 120 ++++++++++
src/components/mailFilter/UpdateModal.vue | 177 ++++++++++++++
src/service/MailFilterService.js | 22 ++
src/store/mailFilterStore.js | 52 +++++
.../Service/AllowedRecipientsServiceTest.php | 54 +++++
.../Service/MailFilter/FilterBuilderTest.php | 65 ++++++
.../Service/MailFilter/FilterParserTest.php | 77 +++++++
tests/Unit/Service/OutOfOfficeServiceTest.php | 20 +-
tests/data/mail-filter/builder1.json | 24 ++
tests/data/mail-filter/builder1.sieve | 11 +
tests/data/mail-filter/builder2.json | 31 +++
tests/data/mail-filter/builder2.sieve | 11 +
tests/data/mail-filter/builder3.json | 55 +++++
tests/data/mail-filter/builder3.sieve | 16 ++
tests/data/mail-filter/builder4.json | 15 ++
tests/data/mail-filter/builder4.sieve | 6 +
tests/data/mail-filter/builder5.json | 27 +++
tests/data/mail-filter/builder5.sieve | 12 +
tests/data/mail-filter/builder6.json | 36 +++
tests/data/mail-filter/builder6.sieve | 12 +
tests/data/mail-filter/parser1.sieve | 11 +
tests/data/mail-filter/parser2.sieve | 11 +
41 files changed, 1925 insertions(+), 31 deletions(-)
create mode 100644 lib/Controller/MailfilterController.php
create mode 100644 lib/Exception/FilterParserException.php
create mode 100644 lib/Service/AllowedRecipientsService.php
create mode 100644 lib/Service/MailFilter/FilterBuilder.php
create mode 100644 lib/Service/MailFilter/FilterParser.php
create mode 100644 lib/Service/MailFilter/FilterParserResult.php
create mode 100644 lib/Service/MailFilter/FilterState.php
create mode 100644 lib/Service/MailFilterService.php
create mode 100644 src/components/mailFilter/Action.vue
create mode 100644 src/components/mailFilter/ActionAddflag.vue
create mode 100644 src/components/mailFilter/ActionFileinto.vue
create mode 100644 src/components/mailFilter/ActionStop.vue
create mode 100644 src/components/mailFilter/DeleteModal.vue
create mode 100644 src/components/mailFilter/MailFilters.vue
create mode 100644 src/components/mailFilter/Operator.vue
create mode 100644 src/components/mailFilter/Test.vue
create mode 100644 src/components/mailFilter/UpdateModal.vue
create mode 100644 src/service/MailFilterService.js
create mode 100644 src/store/mailFilterStore.js
create mode 100644 tests/Unit/Service/AllowedRecipientsServiceTest.php
create mode 100644 tests/Unit/Service/MailFilter/FilterBuilderTest.php
create mode 100644 tests/Unit/Service/MailFilter/FilterParserTest.php
create mode 100644 tests/data/mail-filter/builder1.json
create mode 100644 tests/data/mail-filter/builder1.sieve
create mode 100644 tests/data/mail-filter/builder2.json
create mode 100644 tests/data/mail-filter/builder2.sieve
create mode 100644 tests/data/mail-filter/builder3.json
create mode 100644 tests/data/mail-filter/builder3.sieve
create mode 100644 tests/data/mail-filter/builder4.json
create mode 100644 tests/data/mail-filter/builder4.sieve
create mode 100644 tests/data/mail-filter/builder5.json
create mode 100644 tests/data/mail-filter/builder5.sieve
create mode 100644 tests/data/mail-filter/builder6.json
create mode 100644 tests/data/mail-filter/builder6.sieve
create mode 100644 tests/data/mail-filter/parser1.sieve
create mode 100644 tests/data/mail-filter/parser2.sieve
diff --git a/REUSE.toml b/REUSE.toml
index df7fb28efa..1e2a9364cb 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -101,6 +101,12 @@ precedence = "aggregate"
SPDX-FileCopyrightText = "2024 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "AGPL-3.0-or-later"
+[[annotations]]
+path = ["tests/data/mail-filter/*"]
+precedence = "aggregate"
+SPDX-FileCopyrightText = "2024 Nextcloud GmbH and Nextcloud contributors"
+SPDX-License-Identifier = "AGPL-3.0-or-later"
+
[[annotations]]
path = ".github/CODEOWNERS"
precedence = "aggregate"
diff --git a/lib/Controller/MailfilterController.php b/lib/Controller/MailfilterController.php
new file mode 100644
index 0000000000..7609c0559a
--- /dev/null
+++ b/lib/Controller/MailfilterController.php
@@ -0,0 +1,58 @@
+currentUserId = $userId;
+ }
+
+ #[Route(Route::TYPE_FRONTPAGE, verb: 'GET', url: '/api/mailfilter/{accountId}', requirements: ['accountId' => '[\d]+'])]
+ public function getFilters(int $accountId) {
+ $account = $this->accountService->findById($accountId);
+
+ if ($account->getUserId() !== $this->currentUserId) {
+ return new JSONResponse([], Http::STATUS_NOT_FOUND);
+ }
+
+ $result = $this->mailFilterService->parse($account->getMailAccount());
+
+ return new JSONResponse($result->getFilters());
+ }
+
+ #[Route(Route::TYPE_FRONTPAGE, verb: 'PUT', url: '/api/mailfilter/{accountId}', requirements: ['accountId' => '[\d]+'])]
+ public function updateFilters(int $accountId, array $filters) {
+ $account = $this->accountService->findById($accountId);
+
+ if ($account->getUserId() !== $this->currentUserId) {
+ return new JSONResponse([], Http::STATUS_NOT_FOUND);
+ }
+
+ $this->mailFilterService->update($account->getMailAccount(), $filters);
+
+ return new JSONResponse([]);
+ }
+}
diff --git a/lib/Exception/FilterParserException.php b/lib/Exception/FilterParserException.php
new file mode 100644
index 0000000000..a6ab6d8669
--- /dev/null
+++ b/lib/Exception/FilterParserException.php
@@ -0,0 +1,29 @@
+getMessage(),
+ 0,
+ $exception,
+ );
+ }
+
+ public static function invalidState(): FilterParserException {
+ return new self(
+ 'Reached an invalid state',
+ );
+ }
+}
diff --git a/lib/Service/AllowedRecipientsService.php b/lib/Service/AllowedRecipientsService.php
new file mode 100644
index 0000000000..f97e03338a
--- /dev/null
+++ b/lib/Service/AllowedRecipientsService.php
@@ -0,0 +1,34 @@
+ $alias->getAlias(),
+ $this->aliasesService->findAll($mailAccount->getId(), $mailAccount->getUserId())
+ );
+
+ return array_merge([$mailAccount->getEmail()], $aliases);
+ }
+}
diff --git a/lib/Service/MailFilter/FilterBuilder.php b/lib/Service/MailFilter/FilterBuilder.php
new file mode 100644
index 0000000000..6d47c4e342
--- /dev/null
+++ b/lib/Service/MailFilter/FilterBuilder.php
@@ -0,0 +1,154 @@
+sanitizeFlag($action['flag']))
+ );
+ }
+ if ($action['type'] === 'keep') {
+ $actions[] = 'keep;';
+ }
+ if ($action['type'] === 'stop') {
+ $actions[] = 'stop;';
+ }
+ }
+
+ if (count($tests) > 1) {
+ $ifTest = sprintf('%s (%s)', $filter['operator'], implode(', ', $tests));
+ } else {
+ $ifTest = $tests[0];
+ }
+
+ $ifBlock = sprintf(
+ "if %s {\r\n%s\r\n}",
+ $ifTest,
+ implode(self::SIEVE_NEWLINE, $actions)
+ );
+
+ $commands[] = $ifBlock;
+ }
+
+ $extensions = array_unique($extensions);
+ $requireSection = [];
+
+ if (count($extensions) > 0) {
+ $requireSection[] = self::SEPARATOR;
+ $requireSection[] = 'require ' . SieveUtils::stringList($extensions) . ';';
+ $requireSection[] = self::SEPARATOR;
+ }
+
+ $stateJsonString = json_encode($this->sanitizeDefinition($filters), JSON_THROW_ON_ERROR);
+
+ $filterSection = [
+ self::SEPARATOR,
+ self::DATA_MARKER . $stateJsonString,
+ ...$commands,
+ self::SEPARATOR,
+ ];
+
+ return implode(self::SIEVE_NEWLINE, array_merge(
+ $requireSection,
+ [$untouchedScript],
+ $filterSection,
+ ));
+ }
+
+ private function sanitizeFlag(string $flag): string {
+ try {
+ return $this->imapFlag->create($flag);
+ } catch (ImapFlagEncodingException) {
+ return 'placeholder_for_invalid_label';
+ }
+ }
+
+ private function sanitizeDefinition(array $filters): array {
+ return array_map(static function ($filter) {
+ unset($filter['accountId'], $filter['id']);
+ $filter['tests'] = array_map(static function ($test) {
+ unset($test['id']);
+ return $test;
+ }, $filter['tests']);
+ $filter['actions'] = array_map(static function ($action) {
+ unset($action['id']);
+ return $action;
+ }, $filter['actions']);
+ $filter['priority'] = (int)$filter['priority'];
+ return $filter;
+ }, $filters);
+ }
+}
diff --git a/lib/Service/MailFilter/FilterParser.php b/lib/Service/MailFilter/FilterParser.php
new file mode 100644
index 0000000000..2f52d708de
--- /dev/null
+++ b/lib/Service/MailFilter/FilterParser.php
@@ -0,0 +1,67 @@
+filters;
+ }
+
+ public function getSieveScript(): string {
+ return $this->sieveScript;
+ }
+
+ public function getUntouchedSieveScript(): string {
+ return $this->untouchedSieveScript;
+ }
+
+ #[ReturnTypeWillChange]
+ public function jsonSerialize() {
+ return [
+ 'filters' => $this->filters,
+ 'script' => $this->getSieveScript(),
+ 'untouchedScript' => $this->getUntouchedSieveScript(),
+ ];
+ }
+}
diff --git a/lib/Service/MailFilter/FilterState.php b/lib/Service/MailFilter/FilterState.php
new file mode 100644
index 0000000000..e238da2c57
--- /dev/null
+++ b/lib/Service/MailFilter/FilterState.php
@@ -0,0 +1,35 @@
+filters;
+ }
+
+ #[ReturnTypeWillChange]
+ public function jsonSerialize() {
+ return $this->filters;
+ }
+}
diff --git a/lib/Service/MailFilterService.php b/lib/Service/MailFilterService.php
new file mode 100644
index 0000000000..46a23513ba
--- /dev/null
+++ b/lib/Service/MailFilterService.php
@@ -0,0 +1,89 @@
+sieveService->getActiveScript($account->getUserId(), $account->getId());
+ return $this->filterParser->parseFilterState($script->getScript());
+ }
+
+ /**
+ * @throws CouldNotConnectException
+ * @throws JsonException
+ * @throws ClientException
+ * @throws OutOfOfficeParserException
+ * @throws ManageSieveException
+ * @throws FilterParserException
+ */
+ public function update(MailAccount $account, array $filters): void {
+ $script = $this->sieveService->getActiveScript($account->getUserId(), $account->getId());
+
+ $oooResult = $this->outOfOfficeParser->parseOutOfOfficeState($script->getScript());
+ $filterResult = $this->filterParser->parseFilterState($oooResult->getUntouchedSieveScript());
+
+ $newScript = $this->filterBuilder->buildSieveScript(
+ $filters,
+ $filterResult->getUntouchedSieveScript()
+ );
+
+ $oooState = $oooResult->getState();
+
+ if ($oooState === null) {
+ $newScriptWithOutOfOffice = $newScript;
+ } else {
+ $newScriptWithOutOfOffice = $this->outOfOfficeParser->buildSieveScript(
+ $oooState,
+ $newScript,
+ $this->allowedRecipientsService->get($account),
+ );
+ }
+
+ try {
+ $this->sieveService->updateActiveScript($account->getUserId(), $account->getId(), $newScriptWithOutOfOffice);
+ } catch (ManageSieveException $e) {
+ $this->logger->error('Failed to save sieve script: ' . $e->getMessage(), [
+ 'exception' => $e,
+ 'script' => $newScript,
+ ]);
+ throw $e;
+ }
+ }
+}
diff --git a/lib/Service/OutOfOffice/OutOfOfficeParser.php b/lib/Service/OutOfOffice/OutOfOfficeParser.php
index 79a532514f..28bb2abfd7 100644
--- a/lib/Service/OutOfOffice/OutOfOfficeParser.php
+++ b/lib/Service/OutOfOffice/OutOfOfficeParser.php
@@ -13,6 +13,7 @@
use DateTimeZone;
use JsonException;
use OCA\Mail\Exception\OutOfOfficeParserException;
+use OCA\Mail\Sieve\SieveUtils;
/**
* Parses and builds out-of-office states from/to sieve scripts.
@@ -119,7 +120,7 @@ public function buildSieveScript(
$condition = "currentdate :value \"ge\" \"iso8601\" \"$formattedStart\"";
}
- $escapedSubject = $this->escapeStringForSieve($state->getSubject());
+ $escapedSubject = SieveUtils::escapeString($state->getSubject());
$vacation = [
'vacation',
':days 4',
@@ -134,7 +135,7 @@ public function buildSieveScript(
$vacation[] = ":addresses [$joinedRecipients]";
}
- $escapedMessage = $this->escapeStringForSieve($state->getMessage());
+ $escapedMessage = SieveUtils::escapeString($state->getMessage());
$vacation[] = "\"$escapedMessage\"";
$vacationCommand = implode(' ', $vacation);
@@ -183,9 +184,4 @@ public function buildSieveScript(
private function formatDateForSieve(DateTimeImmutable $date): string {
return $date->setTimezone($this->utc)->format('Y-m-d\TH:i:s\Z');
}
-
- private function escapeStringForSieve(string $subject): string {
- $subject = preg_replace('/\\\\/', '\\\\\\\\', $subject);
- return preg_replace('/"/', '\\"', $subject);
- }
}
diff --git a/lib/Service/OutOfOfficeService.php b/lib/Service/OutOfOfficeService.php
index c12569b5f0..f209f021d1 100644
--- a/lib/Service/OutOfOfficeService.php
+++ b/lib/Service/OutOfOfficeService.php
@@ -13,7 +13,6 @@
use Horde\ManageSieve\Exception as ManageSieveException;
use InvalidArgumentException;
use JsonException;
-use OCA\Mail\Db\Alias;
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Exception\ClientException;
use OCA\Mail\Exception\CouldNotConnectException;
@@ -36,8 +35,8 @@ public function __construct(
private OutOfOfficeParser $outOfOfficeParser,
private SieveService $sieveService,
private LoggerInterface $logger,
- private AliasesService $aliasesService,
private ITimeFactory $timeFactory,
+ private AllowedRecipientsService $allowedRecipientsService,
ContainerInterface $container,
) {
// TODO: inject directly if we only support Nextcloud >= 28
@@ -72,7 +71,7 @@ public function update(MailAccount $account, OutOfOfficeState $state): void {
$newScript = $this->outOfOfficeParser->buildSieveScript(
$state,
$oldState->getUntouchedSieveScript(),
- $this->buildAllowedRecipients($account),
+ $this->allowedRecipientsService->get($account),
);
try {
$this->sieveService->updateActiveScript($account->getUserId(), $account->getId(), $newScript);
@@ -155,15 +154,4 @@ public function disable(MailAccount $account): void {
$state->setEnabled(false);
$this->update($account, $state);
}
-
- /**
- * @return string[]
- */
- private function buildAllowedRecipients(MailAccount $mailAccount): array {
- $aliases = $this->aliasesService->findAll($mailAccount->getId(), $mailAccount->getUserId());
- $formattedAliases = array_map(static function (Alias $alias) {
- return $alias->getAlias();
- }, $aliases);
- return array_merge([$mailAccount->getEmail()], $formattedAliases);
- }
}
diff --git a/src/components/AccountSettings.vue b/src/components/AccountSettings.vue
index 703ee899a0..d4a61327f6 100644
--- a/src/components/AccountSettings.vue
+++ b/src/components/AccountSettings.vue
@@ -55,6 +55,13 @@
{{ t('mail', 'Please connect to a sieve server first.') }}
+
+
+
+
+
@@ -104,6 +111,7 @@ import CertificateSettings from './CertificateSettings.vue'
import SearchSettings from './SearchSettings.vue'
import TrashRetentionSettings from './TrashRetentionSettings.vue'
import logger from '../logger.js'
+import MailFilters from './mailFilter/MailFilters.vue'
export default {
name: 'AccountSettings',
@@ -121,6 +129,7 @@ export default {
CertificateSettings,
TrashRetentionSettings,
SearchSettings,
+ MailFilters,
},
props: {
account: {
diff --git a/src/components/mailFilter/Action.vue b/src/components/mailFilter/Action.vue
new file mode 100644
index 0000000000..7f5754d780
--- /dev/null
+++ b/src/components/mailFilter/Action.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ 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..6736fbf50c
--- /dev/null
+++ b/src/components/mailFilter/MailFilters.vue
@@ -0,0 +1,217 @@
+
+
+
+
+
+
+ {{ 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..c26293822b
--- /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..0b03d379a6
--- /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..b7b34a3686
--- /dev/null
+++ b/src/components/mailFilter/UpdateModal.vue
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
+
diff --git a/src/service/MailFilterService.js b/src/service/MailFilterService.js
new file mode 100644
index 0000000000..eaabaf1046
--- /dev/null
+++ b/src/service/MailFilterService.js
@@ -0,0 +1,22 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { generateUrl } from '@nextcloud/router'
+import axios from '@nextcloud/axios'
+
+export async function getFilters(accountId) {
+ const url = generateUrl('/apps/mail/api/mailfilter/{accountId}', { accountId })
+
+ const { data } = await axios.get(url)
+
+ return data
+}
+
+export async function updateFilters(accountId, filters) {
+ const url = generateUrl('/apps/mail/api/mailfilter/{accountId}', { accountId })
+
+ const { data } = await axios.put(url, { filters })
+
+ return data
+}
diff --git a/src/store/mailFilterStore.js b/src/store/mailFilterStore.js
new file mode 100644
index 0000000000..da6d1d6fc2
--- /dev/null
+++ b/src/store/mailFilterStore.js
@@ -0,0 +1,52 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { defineStore } from 'pinia'
+import * as MailFilterService from '../service/MailFilterService.js'
+import { randomId } from '../util/randomId.js'
+
+export default defineStore('mailFilter', {
+ state: () => {
+ return {
+ filters: [],
+ }
+ },
+ actions: {
+ async fetch(accountId) {
+ await this.$patch(async (state) => {
+ const filters = await MailFilterService.getFilters(accountId)
+ state.filters = filters.map((filter) => {
+ filter.id = randomId()
+ filter.tests.map((test) => {
+ test.id = randomId()
+ return test
+ })
+ filter.actions.map((action) => {
+ action.id = randomId()
+ return action
+ })
+ return filter
+ })
+ })
+ },
+ async update(accountId) {
+ let filters = structuredClone(this.filters)
+ filters = filters.map((filter) => {
+ delete filter.id
+ filter.tests.map((test) => {
+ delete test.id
+ return test
+ })
+ filter.actions.map((action) => {
+ delete action.id
+ return action
+ })
+ return filter
+ })
+
+ await MailFilterService.updateFilters(accountId, filters)
+ },
+ },
+})
diff --git a/tests/Unit/Service/AllowedRecipientsServiceTest.php b/tests/Unit/Service/AllowedRecipientsServiceTest.php
new file mode 100644
index 0000000000..ad064d52de
--- /dev/null
+++ b/tests/Unit/Service/AllowedRecipientsServiceTest.php
@@ -0,0 +1,54 @@
+aliasesService = $this->createMock(AliasesService::class);
+ $this->allowedRecipientsService = new AllowedRecipientsService($this->aliasesService);
+ }
+
+ public function testGet(): void {
+ $alias1 = new Alias();
+ $alias1->setAlias('alias1@example.org');
+
+ $alias2 = new Alias();
+ $alias2->setAlias('alias2@example.org');
+
+ $this->aliasesService->expects(self::once())
+ ->method('findAll')
+ ->willReturn([$alias1, $alias2]);
+
+ $mailAccount = new MailAccount();
+ $mailAccount->setId(1);
+ $mailAccount->setUserId('user');
+ $mailAccount->setEmail('user@example.org');
+
+ $recipients = $this->allowedRecipientsService->get($mailAccount);
+
+ $this->assertCount(3, $recipients);
+ $this->assertEquals('user@example.org', $recipients[0]);
+ $this->assertEquals('alias1@example.org', $recipients[1]);
+ $this->assertEquals('alias2@example.org', $recipients[2]);
+ }
+}
diff --git a/tests/Unit/Service/MailFilter/FilterBuilderTest.php b/tests/Unit/Service/MailFilter/FilterBuilderTest.php
new file mode 100644
index 0000000000..5f2ce94c0a
--- /dev/null
+++ b/tests/Unit/Service/MailFilter/FilterBuilderTest.php
@@ -0,0 +1,65 @@
+testFolder = __DIR__ . '/../../../data/mail-filter/';
+ }
+
+ public function setUp(): void {
+ parent::setUp();
+ $this->builder = new FilterBuilder(new ImapFlag());
+ }
+
+ /**
+ * @dataProvider dataBuild
+ */
+ public function testBuild(string $testName): void {
+ $untouchedScript = '# Hello, this is a test';
+
+ $filters = json_decode(
+ file_get_contents($this->testFolder . $testName . '.json'),
+ true,
+ 512,
+ JSON_THROW_ON_ERROR
+ );
+
+ $script = $this->builder->buildSieveScript($filters, $untouchedScript);
+
+ // the .sieve files have \r\n line endings
+ $script .= "\r\n";
+
+ $this->assertStringEqualsFile(
+ $this->testFolder . $testName . '.sieve',
+ $script
+ );
+ }
+
+ public function dataBuild(): array {
+ $files = glob($this->testFolder . 'builder*.json');
+ $tests = [];
+
+ foreach($files as $file) {
+ $filename = pathinfo($file, PATHINFO_FILENAME);
+ $tests[$filename] = [$filename];
+ }
+
+ return $tests;
+ }
+}
diff --git a/tests/Unit/Service/MailFilter/FilterParserTest.php b/tests/Unit/Service/MailFilter/FilterParserTest.php
new file mode 100644
index 0000000000..83e775df64
--- /dev/null
+++ b/tests/Unit/Service/MailFilter/FilterParserTest.php
@@ -0,0 +1,77 @@
+testFolder = __DIR__ . '/../../../data/mail-filter/';
+ }
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->filterParser = new FilterParser();
+ }
+
+ public function testParse1(): void {
+ $script = file_get_contents($this->testFolder . 'parser1.sieve');
+
+ $state = $this->filterParser->parseFilterState($script);
+ $filters = $state->getFilters();
+
+ $this->assertCount(1, $filters);
+ $this->assertSame('Test 1', $filters[0]['name']);
+ $this->assertTrue($filters[0]['enable']);
+ $this->assertSame('allof', $filters[0]['operator']);
+ $this->assertSame(10, $filters[0]['priority']);
+
+ $this->assertCount(1, $filters[0]['tests']);
+ $this->assertSame('from', $filters[0]['tests'][0]['field']);
+ $this->assertSame('is', $filters[0]['tests'][0]['operator']);
+ $this->assertEquals(['alice@example.org', 'bob@example.org'], $filters[0]['tests'][0]['values']);
+
+ $this->assertCount(1, $filters[0]['actions']);
+ $this->assertSame('addflag', $filters[0]['actions'][0]['type']);
+ $this->assertSame('Alice and Bob', $filters[0]['actions'][0]['flag']);
+ }
+
+ public function testParse2(): void {
+ $script = file_get_contents($this->testFolder . 'parser2.sieve');
+
+ $state = $this->filterParser->parseFilterState($script);
+ $filters = $state->getFilters();
+
+ $this->assertCount(1, $filters);
+ $this->assertSame('Test 2', $filters[0]['name']);
+ $this->assertTrue($filters[0]['enable']);
+ $this->assertSame('anyof', $filters[0]['operator']);
+ $this->assertSame(20, $filters[0]['priority']);
+
+ $this->assertCount(2, $filters[0]['tests']);
+ $this->assertSame('subject', $filters[0]['tests'][0]['field']);
+ $this->assertSame('contains', $filters[0]['tests'][0]['operator']);
+ $this->assertEquals(['Project-A', 'Project-B'], $filters[0]['tests'][0]['values']);
+ $this->assertSame('from', $filters[0]['tests'][1]['field']);
+ $this->assertSame('is', $filters[0]['tests'][1]['operator']);
+ $this->assertEquals(['john@example.org'], $filters[0]['tests'][1]['values']);
+
+ $this->assertCount(1, $filters[0]['actions']);
+ $this->assertSame('fileinto', $filters[0]['actions'][0]['type']);
+ $this->assertSame('Test Data', $filters[0]['actions'][0]['mailbox']);
+ }
+}
diff --git a/tests/Unit/Service/OutOfOfficeServiceTest.php b/tests/Unit/Service/OutOfOfficeServiceTest.php
index 1e3aa91ec7..d2afbb7631 100644
--- a/tests/Unit/Service/OutOfOfficeServiceTest.php
+++ b/tests/Unit/Service/OutOfOfficeServiceTest.php
@@ -151,11 +151,11 @@ public function testUpdateFromSystemWithEnabledOutOfOffice(?IOutOfOfficeData $da
['email@domain.com'],
)
->willReturn('# new sieve script');
- $aliasesService = $this->serviceMock->getParameter('aliasesService');
- $aliasesService->expects(self::once())
- ->method('findAll')
- ->with(1, 'user')
- ->willReturn([]);
+ $allowedRecipientsService = $this->serviceMock->getParameter('allowedRecipientsService');
+ $allowedRecipientsService->expects(self::once())
+ ->method('get')
+ ->with($mailAccount)
+ ->willReturn(['email@domain.com']);
$sieveService->expects(self::once())
->method('updateActiveScript')
->with('user', 1, '# new sieve script');
@@ -229,11 +229,11 @@ public function testUpdateFromSystemWithDisabledOutOfOffice(?IOutOfOfficeData $d
['email@domain.com'],
)
->willReturn('# new sieve script');
- $aliasesService = $this->serviceMock->getParameter('aliasesService');
- $aliasesService->expects(self::once())
- ->method('findAll')
- ->with(1, 'user')
- ->willReturn([]);
+ $allowedRecipientsService = $this->serviceMock->getParameter('allowedRecipientsService');
+ $allowedRecipientsService->expects(self::once())
+ ->method('get')
+ ->with($mailAccount)
+ ->willReturn(['email@domain.com']);
$sieveService->expects(self::once())
->method('updateActiveScript')
->with('user', 1, '# new sieve script');
diff --git a/tests/data/mail-filter/builder1.json b/tests/data/mail-filter/builder1.json
new file mode 100644
index 0000000000..35ac4a6709
--- /dev/null
+++ b/tests/data/mail-filter/builder1.json
@@ -0,0 +1,24 @@
+[
+ {
+ "name": "Test 1",
+ "enable": true,
+ "operator": "allof",
+ "tests": [
+ {
+ "operator": "is",
+ "values": [
+ "alice@example.org",
+ "bob@example.org"
+ ],
+ "field": "from"
+ }
+ ],
+ "actions": [
+ {
+ "type": "addflag",
+ "flag": "Alice and Bob"
+ }
+ ],
+ "priority": 10
+ }
+]
diff --git a/tests/data/mail-filter/builder1.sieve b/tests/data/mail-filter/builder1.sieve
new file mode 100644
index 0000000000..d20750752a
--- /dev/null
+++ b/tests/data/mail-filter/builder1.sieve
@@ -0,0 +1,11 @@
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+require ["imap4flags"];
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# Hello, this is a test
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# FILTER: [{"name":"Test 1","enable":true,"operator":"allof","tests":[{"operator":"is","values":["alice@example.org","bob@example.org"],"field":"from"}],"actions":[{"type":"addflag","flag":"Alice and Bob"}],"priority":10}]
+# Test 1
+if address :is :all "From" ["alice@example.org", "bob@example.org"] {
+addflag "$alice_and_bob";
+}
+### Nextcloud Mail: Filters ### DON'T EDIT ###
diff --git a/tests/data/mail-filter/builder2.json b/tests/data/mail-filter/builder2.json
new file mode 100644
index 0000000000..d431502edc
--- /dev/null
+++ b/tests/data/mail-filter/builder2.json
@@ -0,0 +1,31 @@
+[
+ {
+ "name": "Test 2",
+ "enable": true,
+ "operator": "anyof",
+ "tests": [
+ {
+ "operator": "contains",
+ "values": [
+ "Project-A",
+ "Project-B"
+ ],
+ "field": "subject"
+ },
+ {
+ "operator": "is",
+ "values": [
+ "john@example.org"
+ ],
+ "field": "from"
+ }
+ ],
+ "actions": [
+ {
+ "type": "fileinto",
+ "mailbox": "Test Data"
+ }
+ ],
+ "priority": "20"
+ }
+]
diff --git a/tests/data/mail-filter/builder2.sieve b/tests/data/mail-filter/builder2.sieve
new file mode 100644
index 0000000000..683739fc83
--- /dev/null
+++ b/tests/data/mail-filter/builder2.sieve
@@ -0,0 +1,11 @@
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+require ["fileinto"];
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# Hello, this is a test
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# FILTER: [{"name":"Test 2","enable":true,"operator":"anyof","tests":[{"operator":"contains","values":["Project-A","Project-B"],"field":"subject"},{"operator":"is","values":["john@example.org"],"field":"from"}],"actions":[{"type":"fileinto","mailbox":"Test Data"}],"priority":20}]
+# Test 2
+if anyof (header :contains "Subject" ["Project-A", "Project-B"], address :is :all "From" ["john@example.org"]) {
+fileinto "Test Data";
+}
+### Nextcloud Mail: Filters ### DON'T EDIT ###
diff --git a/tests/data/mail-filter/builder3.json b/tests/data/mail-filter/builder3.json
new file mode 100644
index 0000000000..dd7b4583d7
--- /dev/null
+++ b/tests/data/mail-filter/builder3.json
@@ -0,0 +1,55 @@
+[
+ {
+ "name": "Test 3.1",
+ "enable": true,
+ "operator": "anyof",
+ "tests": [
+ {
+ "operator": "contains",
+ "values": [
+ "Project-A",
+ "Project-B"
+ ],
+ "field": "subject"
+ },
+ {
+ "operator": "is",
+ "values": [
+ "john@example.org"
+ ],
+ "field": "from"
+ }
+ ],
+ "actions": [
+ {
+ "type": "fileinto",
+ "mailbox": "Test Data"
+ },
+ {
+ "type": "stop"
+ }
+ ],
+ "priority": "20"
+ },
+ {
+ "name": "Test 3.2",
+ "enable": true,
+ "operator": "allof",
+ "tests": [
+ {
+ "operator": "contains",
+ "values": [
+ "@example.org"
+ ],
+ "field": "to"
+ }
+ ],
+ "actions": [
+ {
+ "type": "addflag",
+ "flag": "Test A"
+ }
+ ],
+ "priority": 30
+ }
+]
diff --git a/tests/data/mail-filter/builder3.sieve b/tests/data/mail-filter/builder3.sieve
new file mode 100644
index 0000000000..71c197d181
--- /dev/null
+++ b/tests/data/mail-filter/builder3.sieve
@@ -0,0 +1,16 @@
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+require ["fileinto", "imap4flags"];
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# Hello, this is a test
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# FILTER: [{"name":"Test 3.1","enable":true,"operator":"anyof","tests":[{"operator":"contains","values":["Project-A","Project-B"],"field":"subject"},{"operator":"is","values":["john@example.org"],"field":"from"}],"actions":[{"type":"fileinto","mailbox":"Test Data"},{"type":"stop"}],"priority":20},{"name":"Test 3.2","enable":true,"operator":"allof","tests":[{"operator":"contains","values":["@example.org"],"field":"to"}],"actions":[{"type":"addflag","flag":"Test A"}],"priority":30}]
+# Test 3.1
+if anyof (header :contains "Subject" ["Project-A", "Project-B"], address :is :all "From" ["john@example.org"]) {
+fileinto "Test Data";
+stop;
+}
+# Test 3.2
+if address :contains :all "To" ["@example.org"] {
+addflag "$test_a";
+}
+### Nextcloud Mail: Filters ### DON'T EDIT ###
diff --git a/tests/data/mail-filter/builder4.json b/tests/data/mail-filter/builder4.json
new file mode 100644
index 0000000000..c02d4c469d
--- /dev/null
+++ b/tests/data/mail-filter/builder4.json
@@ -0,0 +1,15 @@
+[
+ {
+ "actions": [
+ {
+ "flag": "Flag 123",
+ "type": "addflag"
+ }
+ ],
+ "enable": true,
+ "name": "Test 4",
+ "operator": "allof",
+ "priority": 60,
+ "tests": []
+ }
+]
diff --git a/tests/data/mail-filter/builder4.sieve b/tests/data/mail-filter/builder4.sieve
new file mode 100644
index 0000000000..2877d7de01
--- /dev/null
+++ b/tests/data/mail-filter/builder4.sieve
@@ -0,0 +1,6 @@
+# Hello, this is a test
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# FILTER: [{"actions":[{"flag":"Flag 123","type":"addflag"}],"enable":true,"name":"Test 4","operator":"allof","priority":60,"tests":[]}]
+# Test 4
+# No valid tests found
+### Nextcloud Mail: Filters ### DON'T EDIT ###
diff --git a/tests/data/mail-filter/builder5.json b/tests/data/mail-filter/builder5.json
new file mode 100644
index 0000000000..9cc8c89f45
--- /dev/null
+++ b/tests/data/mail-filter/builder5.json
@@ -0,0 +1,27 @@
+[
+ {
+ "actions": [
+ {
+ "flag": "Report",
+ "type": "addflag"
+ },
+ {
+ "flag": "To read",
+ "type": "addflag"
+ }
+ ],
+ "enable": true,
+ "name": "Test 5",
+ "operator": "allof",
+ "priority": 10,
+ "tests": [
+ {
+ "field": "subject",
+ "operator": "matches",
+ "values": [
+ "work*report"
+ ]
+ }
+ ]
+ }
+]
diff --git a/tests/data/mail-filter/builder5.sieve b/tests/data/mail-filter/builder5.sieve
new file mode 100644
index 0000000000..8aad19b928
--- /dev/null
+++ b/tests/data/mail-filter/builder5.sieve
@@ -0,0 +1,12 @@
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+require ["imap4flags"];
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# Hello, this is a test
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# FILTER: [{"actions":[{"flag":"Report","type":"addflag"},{"flag":"To read","type":"addflag"}],"enable":true,"name":"Test 5","operator":"allof","priority":10,"tests":[{"field":"subject","operator":"matches","values":["work*report"]}]}]
+# Test 5
+if header :matches "Subject" ["work*report"] {
+addflag "$report";
+addflag "$to_read";
+}
+### Nextcloud Mail: Filters ### DON'T EDIT ###
diff --git a/tests/data/mail-filter/builder6.json b/tests/data/mail-filter/builder6.json
new file mode 100644
index 0000000000..82c8131c1c
--- /dev/null
+++ b/tests/data/mail-filter/builder6.json
@@ -0,0 +1,36 @@
+[
+ {
+ "actions": [
+ {
+ "mailbox": "Test Data",
+ "type": "fileinto"
+ },
+ {
+ "flag": "Projects\\Reporting",
+ "type": "addflag"
+ }
+ ],
+ "enable": true,
+ "name": "Test 6",
+ "operator": "anyof",
+ "priority": 10,
+ "tests": [
+ {
+ "field": "subject",
+ "operator": "is",
+ "values": [
+ "\"Project-A\"",
+ "Project\\A"
+ ]
+ },
+ {
+ "field": "subject",
+ "operator": "is",
+ "values": [
+ "\"Project-B\"",
+ "Project\\B"
+ ]
+ }
+ ]
+ }
+]
diff --git a/tests/data/mail-filter/builder6.sieve b/tests/data/mail-filter/builder6.sieve
new file mode 100644
index 0000000000..e5a756d66e
--- /dev/null
+++ b/tests/data/mail-filter/builder6.sieve
@@ -0,0 +1,12 @@
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+require ["fileinto", "imap4flags"];
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# Hello, this is a test
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# FILTER: [{"actions":[{"mailbox":"Test Data","type":"fileinto"},{"flag":"Projects\\Reporting","type":"addflag"}],"enable":true,"name":"Test 6","operator":"anyof","priority":10,"tests":[{"field":"subject","operator":"is","values":["\"Project-A\"","Project\\A"]},{"field":"subject","operator":"is","values":["\"Project-B\"","Project\\B"]}]}]
+# Test 6
+if anyof (header :is "Subject" ["\"Project-A\"", "Project\\A"], header :is "Subject" ["\"Project-B\"", "Project\\B"]) {
+fileinto "Test Data";
+addflag "$projects\\reporting";
+}
+### Nextcloud Mail: Filters ### DON'T EDIT ###
diff --git a/tests/data/mail-filter/parser1.sieve b/tests/data/mail-filter/parser1.sieve
new file mode 100644
index 0000000000..d20750752a
--- /dev/null
+++ b/tests/data/mail-filter/parser1.sieve
@@ -0,0 +1,11 @@
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+require ["imap4flags"];
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# Hello, this is a test
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# FILTER: [{"name":"Test 1","enable":true,"operator":"allof","tests":[{"operator":"is","values":["alice@example.org","bob@example.org"],"field":"from"}],"actions":[{"type":"addflag","flag":"Alice and Bob"}],"priority":10}]
+# Test 1
+if address :is :all "From" ["alice@example.org", "bob@example.org"] {
+addflag "$alice_and_bob";
+}
+### Nextcloud Mail: Filters ### DON'T EDIT ###
diff --git a/tests/data/mail-filter/parser2.sieve b/tests/data/mail-filter/parser2.sieve
new file mode 100644
index 0000000000..10e91a2543
--- /dev/null
+++ b/tests/data/mail-filter/parser2.sieve
@@ -0,0 +1,11 @@
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+require ["fileinto"];
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# Hello, this is a test
+### Nextcloud Mail: Filters ### DON'T EDIT ###
+# FILTER: [{"name":"Test 2","enable":true,"operator":"anyof","tests":[{"operator":"contains","values":["Project-A","Project-B"],"field":"subject"},{"operator":"is","values":["john@example.org"],"field":"from"}],"actions":[{"type":"fileinto","flag":"","mailbox":"Test Data"}],"priority":20}]
+# Test 2
+if anyof (header :contains "Subject" ["Project-A", "Project-B"], address :is :all "From" ["john@example.org"]) {
+fileinto "Test Data";
+}
+### Nextcloud Mail: Filters ### DON'T EDIT ###
From d877e5eefa08fd87f7258621e84fa508af202ae2 Mon Sep 17 00:00:00 2001
From: Daniel Kesselberg
Date: Wed, 9 Oct 2024 17:22:54 +0200
Subject: [PATCH 4/6] fixup! feat: mail filters
Signed-off-by: Daniel Kesselberg
---
src/components/mailFilter/Operator.vue | 2 +-
src/components/mailFilter/Test.vue | 2 +-
src/components/mailFilter/UpdateModal.vue | 19 +++++++++++++++++--
3 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/src/components/mailFilter/Operator.vue b/src/components/mailFilter/Operator.vue
index c26293822b..16ba5639f0 100644
--- a/src/components/mailFilter/Operator.vue
+++ b/src/components/mailFilter/Operator.vue
@@ -5,7 +5,7 @@
-
{{ t('mail', 'Operator') }}
+
{{ t('mail', 'Operator') }}
diff --git a/src/components/mailFilter/UpdateModal.vue b/src/components/mailFilter/UpdateModal.vue
index b7b34a3686..3f6f0e5b7a 100644
--- a/src/components/mailFilter/UpdateModal.vue
+++ b/src/components/mailFilter/UpdateModal.vue
@@ -19,7 +19,21 @@
-
{{ t('mail', 'Tests') }}
+
{{ 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', 'Actions') }}
+
{{ t('mail', 'Actions') }}
+
Date: Wed, 9 Oct 2024 17:31:36 +0200
Subject: [PATCH 5/6] fixup! feat: mail filters
Signed-off-by: Daniel Kesselberg
---
src/components/mailFilter/UpdateModal.vue | 30 ++++++++++++++---------
1 file changed, 18 insertions(+), 12 deletions(-)
diff --git a/src/components/mailFilter/UpdateModal.vue b/src/components/mailFilter/UpdateModal.vue
index 3f6f0e5b7a..4279f0396c 100644
--- a/src/components/mailFilter/UpdateModal.vue
+++ b/src/components/mailFilter/UpdateModal.vue
@@ -21,18 +21,20 @@
{{ 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', '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".') }}
+
+
From 711530ceda638a8deeb04f56577afec7f14575f9 Mon Sep 17 00:00:00 2001
From: Daniel Kesselberg
Date: Wed, 9 Oct 2024 18:00:01 +0200
Subject: [PATCH 6/6] fixup! feat: mail filters
Signed-off-by: Daniel Kesselberg
---
src/components/mailFilter/UpdateModal.vue | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/src/components/mailFilter/UpdateModal.vue b/src/components/mailFilter/UpdateModal.vue
index 4279f0396c..e14e56a297 100644
--- a/src/components/mailFilter/UpdateModal.vue
+++ b/src/components/mailFilter/UpdateModal.vue
@@ -52,6 +52,21 @@
{{ 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.') }}
+
+
+