Co-authored-by: Hamza Mahjoubi <hamzamahjoubi221@gmail.com>
Signed-off-by: Daniel Kesselberg <mail@danielkesselberg.de>
This commit is contained in:
Daniel Kesselberg 2024-10-10 21:58:53 +02:00
Родитель 9486987428
Коммит d74c401e66
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4A81C29F63464E8F
56 изменённых файлов: 2481 добавлений и 36 удалений

Просмотреть файл

@ -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"

Просмотреть файл

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail\Controller;
use OCA\Mail\AppInfo\Application;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\FilterService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\Route;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
class FilterController extends OCSController {
private string $currentUserId;
public function __construct(
IRequest $request,
string $userId,
private FilterService $mailFilterService,
private AccountService $accountService,
) {
parent::__construct(Application::APP_ID, $request);
$this->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([]);
}
}

Просмотреть файл

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail\Exception;
use Exception;
class FilterParserException extends Exception {
public static function invalidJson(\Throwable $exception): FilterParserException {
return new self(
'Failed to parse filter state json: ' . $exception->getMessage(),
0,
$exception,
);
}
public static function invalidState(): FilterParserException {
return new self(
'Reached an invalid state',
);
}
}

Просмотреть файл

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail\Service;
use OCA\Mail\Db\MailAccount;
class AllowedRecipientsService {
public function __construct(
private AliasesService $aliasesService,
) {
}
/**
* Return a list of allowed recipients for a given mail account
*
* @return string[] email addresses
*/
public function get(MailAccount $mailAccount): array {
$aliases = array_map(
static fn ($alias) => $alias->getAlias(),
$this->aliasesService->findAll($mailAccount->getId(), $mailAccount->getUserId())
);
return array_merge([$mailAccount->getEmail()], $aliases);
}
}

Просмотреть файл

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail\Service;
use Horde\ManageSieve\Exception as ManageSieveException;
use JsonException;
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Exception\ClientException;
use OCA\Mail\Exception\CouldNotConnectException;
use OCA\Mail\Exception\FilterParserException;
use OCA\Mail\Exception\OutOfOfficeParserException;
use OCA\Mail\Service\MailFilter\FilterBuilder;
use OCA\Mail\Service\MailFilter\FilterParser;
use OCA\Mail\Service\MailFilter\FilterParserResult;
use OCA\Mail\Service\OutOfOffice\OutOfOfficeParser;
use OCA\Mail\Service\OutOfOffice\OutOfOfficeState;
use Psr\Log\LoggerInterface;
class FilterService {
public function __construct(
private AllowedRecipientsService $allowedRecipientsService,
private OutOfOfficeParser $outOfOfficeParser,
private FilterParser $filterParser,
private FilterBuilder $filterBuilder,
private SieveService $sieveService,
private LoggerInterface $logger,
) {
}
/**
* @throws ClientException
* @throws ManageSieveException
* @throws CouldNotConnectException
* @throws FilterParserException
*/
public function parse(MailAccount $account): FilterParserResult {
$script = $this->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;
}
}
}

Просмотреть файл

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail\Service\MailFilter;
use OCA\Mail\Exception\ImapFlagEncodingException;
use OCA\Mail\IMAP\ImapFlag;
use OCA\Mail\Sieve\SieveUtils;
class FilterBuilder {
private const SEPARATOR = '### Nextcloud Mail: Filters ### DON\'T EDIT ###';
private const DATA_MARKER = '# FILTER: ';
private const SIEVE_NEWLINE = "\r\n";
public function __construct(
private ImapFlag $imapFlag,
) {
}
public function buildSieveScript(array $filters, string $untouchedScript): string {
$commands = [];
$extensions = [];
foreach ($filters as $filter) {
if ($filter['enable'] === false) {
continue;
}
$commands[] = '# ' . $filter['name'];
$tests = [];
foreach ($filter['tests'] as $test) {
if ($test['field'] === 'subject') {
$tests[] = sprintf(
'header :%s "Subject" %s',
$test['operator'],
SieveUtils::stringList($test['values']),
);
}
if ($test['field'] === 'to') {
$tests[] = sprintf(
'address :%s :all "To" %s',
$test['operator'],
SieveUtils::stringList($test['values']),
);
}
if ($test['field'] === 'from') {
$tests[] = sprintf(
'address :%s :all "From" %s',
$test['operator'],
SieveUtils::stringList($test['values']),
);
}
}
if (count($tests) === 0) {
// skip filter without tests
$commands[] = '# No valid tests found';
continue;
}
$actions = [];
foreach ($filter['actions'] as $action) {
if ($action['type'] === 'fileinto') {
$extensions[] = 'fileinto';
$actions[] = sprintf(
'fileinto "%s";',
SieveUtils::escapeString($action['mailbox'])
);
}
if ($action['type'] === 'addflag') {
$extensions[] = 'imap4flags';
$actions[] = sprintf(
'addflag "%s";',
SieveUtils::escapeString($this->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);
}
}

Просмотреть файл

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail\Service\MailFilter;
use JsonException;
use OCA\Mail\Exception\FilterParserException;
class FilterParser {
private const SEPARATOR = '### Nextcloud Mail: Filters ### DON\'T EDIT ###';
private const DATA_MARKER = '# FILTER: ';
private const STATE_COPY = 0;
private const STATE_SKIP = 1;
/**
* @throws FilterParserException
*/
public function parseFilterState(string $sieveScript): FilterParserResult {
$filters = [];
$scriptOut = [];
$state = self::STATE_COPY;
$nextState = $state;
$lines = preg_split('/\r?\n/', $sieveScript);
foreach ($lines as $line) {
switch ($state) {
case self::STATE_COPY:
if (str_starts_with($line, self::SEPARATOR)) {
$nextState = self::STATE_SKIP;
} else {
$scriptOut[] = $line;
}
break;
case self::STATE_SKIP:
if (str_starts_with($line, self::SEPARATOR)) {
$nextState = self::STATE_COPY;
} elseif (str_starts_with($line, self::DATA_MARKER)) {
$json = substr($line, strlen(self::DATA_MARKER));
try {
$data = json_decode($json, true, 10, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw FilterParserException::invalidJson($e);
}
if (is_array($data)) {
array_push($filters, ...$data);
}
}
break;
default:
throw FilterParserException::invalidState();
}
$state = $nextState;
}
return new FilterParserResult($filters, $sieveScript, implode("\r\n", $scriptOut));
}
}

Просмотреть файл

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail\Service\MailFilter;
use JsonSerializable;
use ReturnTypeWillChange;
class FilterParserResult implements JsonSerializable {
public function __construct(
private array $filters,
private string $sieveScript,
private string $untouchedSieveScript,
) {
}
public function getFilters(): array {
return $this->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(),
];
}
}

Просмотреть файл

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail\Service\MailFilter;
use JsonSerializable;
use ReturnTypeWillChange;
class FilterState implements JsonSerializable {
public const DEFAULT_VERSION = 1;
public function __construct(
private array $filters,
) {
}
public static function fromJson(array $data): self {
return new self($data);
}
public function getFilters(): array {
return $this->filters;
}
#[ReturnTypeWillChange]
public function jsonSerialize() {
return $this->filters;
}
}

Просмотреть файл

@ -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 @@ class MailManager implements IMailManager {
TagMapper $tagMapper,
MessageTagsMapper $messageTagsMapper,
ThreadMapper $threadMapper,
private ImapFlag $imapFlag) {
private ImapFlag $imapFlag,
) {
$this->imapClientFactory = $imapClientFactory;
$this->mailboxMapper = $mailboxMapper;
$this->mailboxSync = $mailboxSync;

Просмотреть файл

@ -13,6 +13,7 @@ use DateTimeImmutable;
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 @@ class OutOfOfficeParser {
$condition = "currentdate :value \"ge\" \"iso8601\" \"$formattedStart\"";
}
$escapedSubject = $this->escapeStringForSieve($state->getSubject());
$escapedSubject = SieveUtils::escapeString($state->getSubject());
$vacation = [
'vacation',
':days 4',
@ -134,7 +135,7 @@ class OutOfOfficeParser {
$vacation[] = ":addresses [$joinedRecipients]";
}
$escapedMessage = $this->escapeStringForSieve($state->getMessage());
$escapedMessage = SieveUtils::escapeString($state->getMessage());
$vacation[] = "\"$escapedMessage\"";
$vacationCommand = implode(' ', $vacation);
@ -183,9 +184,4 @@ class OutOfOfficeParser {
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);
}
}

Просмотреть файл

@ -13,7 +13,6 @@ use DateTimeImmutable;
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 @@ class OutOfOfficeService {
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 @@ class OutOfOfficeService {
$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 @@ class OutOfOfficeService {
$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);
}
}

Просмотреть файл

@ -55,9 +55,16 @@
{{ t('mail', 'Please connect to a sieve server first.') }}
</p>
</AppSettingsSection>
<AppSettingsSection v-if="account && account.sieveEnabled"
id="mail-filters"
:name="t('mail', 'Filters')">
<div id="mail-filters">
<MailFilters :key="account.accountId" ref="mailFilters" :account="account" />
</div>
</AppSettingsSection>
<AppSettingsSection v-if="account && account.sieveEnabled"
id="sieve-filter"
:name="t('mail', 'Sieve filter rules')">
:name="t('mail', 'Sieve script editor')">
<div id="sieve-filter">
<SieveFilterForm :key="account.accountId"
ref="sieveFilterForm"
@ -77,7 +84,7 @@
</AppSettingsSection>
<AppSettingsSection v-if="account && !account.provisioningId"
id="sieve-settings"
:name="t('mail', 'Sieve filter server')">
:name="t('mail', 'Sieve server')">
<div id="sieve-settings">
<SieveAccountForm :key="account.accountId"
ref="sieveAccountForm"
@ -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: {

Просмотреть файл

@ -5,7 +5,7 @@
<template>
<form id="sieve-form">
<p>
{{ 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.') }}
</p>
<p>
<NcCheckboxRadioSwitch :checked.sync="sieveConfig.sieveEnabled">

Просмотреть файл

@ -0,0 +1,98 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="mail-filter-row">
<div class="mail-filter-column">
<NcSelect :value="action.type"
:required="true"
:label-outside="true"
:options="availableTypes"
@input="updateAction({ type: $event })" />
</div>
<div class="mail-filter-column">
<component :is="componentInstance"
v-if="componentInstance"
:action="action"
:account="account"
@update-action="updateAction" />
</div>
<div class="mail-filter-column">
<NcButton aria-label="Delete action"
type="tertiary-no-background"
@click="deleteAction">
{{ t('mail', 'Delete action') }}
<template #icon>
<DeleteIcon :size="20" />
</template>
</NcButton>
</div>
</div>
</template>
<script>
import ActionFileinto from './ActionFileinto.vue'
import ActionAddflag from './ActionAddflag.vue'
import ActionStop from './ActionStop.vue'
import { NcButton, NcSelect, NcTextField } from '@nextcloud/vue'
import DeleteIcon from 'vue-material-design-icons/Delete.vue'
export default {
name: 'Action',
components: {
NcSelect,
NcTextField,
NcButton,
ActionFileinto,
ActionAddflag,
ActionStop,
DeleteIcon,
},
props: {
action: {
type: Object,
required: true,
},
account: {
type: Object,
required: true,
},
},
data() {
return {
availableTypes: [
'addflag',
'fileinto',
'stop',
],
}
},
computed: {
componentInstance() {
if (this.action.type === 'fileinto') {
return ActionFileinto
} else if (this.action.type === 'addflag') {
return ActionAddflag
} else if (this.action.type === 'stop') {
return ActionStop
}
return null
},
},
methods: {
updateAction(properties) {
this.$emit('update-action', { ...this.action, ...properties })
},
deleteAction() {
this.$emit('delete-action', this.action)
},
},
}
</script>
<style lang="scss" scoped>
.mail-filter-row {
display: flex;
gap: 5px;
align-items: baseline;
}
</style>

Просмотреть файл

@ -0,0 +1,45 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcTextField :required="true"
:label="t('mail', 'Flag')"
:value="flag"
@update:value="onInput" />
</template>
<script>
import { NcTextField } from '@nextcloud/vue'
export default {
name: 'ActionAddflag',
components: {
NcTextField,
},
props: {
action: {
type: Object,
required: true,
},
account: {
type: Object,
required: true,
},
},
computed: {
flag() {
return this.action.flag ?? ''
},
},
methods: {
onInput(value) {
this.$emit('update-action', { flag: value })
},
},
}
</script>
<style lang="scss" scoped>
.input-field {
min-width: 260px;
}
</style>

Просмотреть файл

@ -0,0 +1,50 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcSelect ref="select"
:value="mailbox"
:options="mailboxes"
:required="true"
@input="onInput" />
</template>
<script>
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
import { mailboxHasRights } from '../../util/acl.js'
export default {
name: 'ActionFileinto',
components: {
NcSelect,
},
props: {
action: {
type: Object,
required: true,
},
account: {
type: Object,
required: true,
},
},
computed: {
mailbox() {
return this.action.mailbox ?? undefined
},
mailboxes() {
const mailboxes = this.$store.getters.getMailboxes(this.account.accountId)
.filter(mailbox => mailboxHasRights(mailbox, 'i'))
return mailboxes.map((mailbox) => {
return mailbox.displayName
})
},
},
methods: {
onInput(value) {
this.$emit('update-action', { mailbox: value })
},
},
}
</script>

Просмотреть файл

@ -0,0 +1,23 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<p>{{ t('mail', 'The stop action ends all processing') }}</p>
</template>
<script>
export default {
name: 'ActionStop',
props: {
action: {
type: Object,
required: true,
},
account: {
type: Object,
required: true,
},
},
}
</script>

Просмотреть файл

@ -0,0 +1,62 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDialog :name="t('mail', 'Delete mail filter {filterName}?', {filterName: filter.name})"
:open="open"
:message="t('mail', 'Are you sure to delete the mail filter?')"
:buttons="buttons"
@closing="closeModal()" />
</template>
<script>
import { NcDialog } from '@nextcloud/vue'
// eslint-disable-next-line import/no-unresolved
import IconCancel from '@mdi/svg/svg/cancel.svg?raw'
// eslint-disable-next-line import/no-unresolved
import IconCheck from '@mdi/svg/svg/check.svg?raw'
export default {
name: 'DeleteModal',
components: {
NcDialog,
},
props: {
filter: {
type: Object,
required: true,
},
open: {
type: Boolean,
required: true,
},
},
data() {
return {
buttons: [
{
label: t('mail', 'Cancel'),
icon: IconCancel,
callback: () => { this.closeModal() },
},
{
label: t('mail', 'Delete filter'),
type: 'error',
icon: IconCheck,
callback: () => { this.deleteFilter() },
},
],
}
},
methods: {
deleteFilter() {
this.$emit('delete-filter', this.filter)
},
closeModal() {
this.$emit('close')
},
},
}
</script>

Просмотреть файл

@ -0,0 +1,218 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="section">
<p>{{ t('mail', 'Take control of your email chaos. Filters help you to prioritize what matters and eliminate clutter.') }}</p>
<ul>
<NcListItem v-for="filter in filters"
:key="filter.id"
:name="filter.name"
:compact="true"
@click="openUpdateModal(filter)">
<template #subname>
<span v-if="filter.enable">{{ t('mail', 'Filter is active') }}</span>
<span v-else>{{ t('mail', 'Filter is not active') }}</span>
</template>
<template #actions>
<NcActionButton @click="openDeleteModal(filter)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ t('mail', 'Delete filter') }}
</NcActionButton>
</template>
</NcListItem>
</ul>
<NcButton class="app-settings-button"
type="primary"
:aria-label="t('mail', 'New filter')"
@click.prevent.stop="createFilter">
{{ t('mail', 'New filter') }}
</NcButton>
<UpdateModal v-if="showUpdateModal && currentFilter"
:filter="currentFilter"
:account="account"
:loading="loading"
@update-filter="updateFilter"
@close="closeModal" />
<DeleteModal v-if="showDeleteModal && currentFilter"
:filter="currentFilter"
:open="showDeleteModal"
:loading="loading"
@delete-filter="deleteFilter"
@close="closeModal" />
</div>
</template>
<script>
import { NcActionButton, NcListItem, NcButton } from '@nextcloud/vue'
import UpdateModal from './UpdateModal.vue'
import { randomId } from '../../util/randomId.js'
import logger from '../../logger.js'
import { mapStores } from 'pinia'
import useMailFilterStore from '../../store/mailFilterStore.js'
import DeleteIcon from 'vue-material-design-icons/Delete.vue'
import DeleteModal from './DeleteModal.vue'
import { showError, showSuccess } from '@nextcloud/dialogs'
export default {
name: 'MailFilters',
components: {
NcButton,
NcListItem,
NcActionButton,
UpdateModal,
DeleteIcon,
DeleteModal,
},
props: {
account: {
type: Object,
required: true,
},
},
data() {
return {
showUpdateModal: false,
showDeleteModal: false,
script: '',
loading: true,
errorMessage: '',
currentFilter: null,
}
},
computed: {
...mapStores(useMailFilterStore),
filters() {
return this.mailFilterStore.filters
},
scriptData() {
return this.$store.getters.getActiveSieveScript(this.account.id)
},
},
watch: {
scriptData: {
immediate: true,
handler(scriptData) {
if (!scriptData) {
return
}
this.script = scriptData.script
this.loading = false
},
},
},
async mounted() {
await this.mailFilterStore.fetch(this.account.id)
},
methods: {
createFilter() {
const priority = Math.max(0, ...this.filters.map((item) => item.priority ?? 0)) + 10
this.currentFilter = {
id: randomId(),
name: t('mail', 'New filter'),
enable: true,
operator: 'allof',
tests: [],
actions: [],
priority,
}
this.showUpdateModal = true
this.loading = false
},
openUpdateModal(filter) {
this.currentFilter = filter
this.showUpdateModal = true
},
openDeleteModal(filter) {
this.currentFilter = filter
this.showDeleteModal = true
},
async updateFilter(filter) {
this.loading = true
this.mailFilterStore.$patch((state) => {
const index = state.filters.findIndex((item) => item.id === filter.id)
logger.debug('update filter', { filter, index })
if (index === -1) {
state.filters.push(filter)
} else {
state.filters[index] = filter
}
state.filters.sort((a, b) => a.priority - b.priority)
})
try {
await this.mailFilterStore.update(this.account.id).then(() => {
showSuccess(t('mail', 'Filter saved'))
})
await this.$store.dispatch('fetchActiveSieveScript', { accountId: this.account.id })
} catch (e) {
logger.error(e)
showError(t('mail', 'Could not save filter'))
} finally {
this.loading = false
}
},
async deleteFilter(filter) {
this.loading = true
this.mailFilterStore.$patch((state) => {
const index = state.filters.findIndex((item) => item.id === filter.id)
logger.debug('delete filter', { filter, index })
if (index !== -1) {
state.filters.splice(index, 1)
}
})
try {
await this.mailFilterStore.update(this.account.id).then(() => {
showSuccess(t('mail', 'Filter deleted'))
})
} catch (e) {
logger.error(e)
showError(t('mail', 'Could not delete filter'))
} finally {
this.loading = false
}
await this.$store.dispatch('fetchActiveSieveScript', { accountId: this.account.id })
},
closeModal() {
this.currentFilter = null
this.showUpdateModal = false
this.showDeleteModal = false
},
},
}
</script>
<style lang="scss" scoped>
.section {
display: block;
padding: 0;
margin-bottom: 23px;
}
textarea {
width: 100%;
}
.primary {
padding-left: 26px;
background-position: 6px;
color: var(--color-main-background);
&:after {
left: 14px;
}
}
</style>

Просмотреть файл

@ -0,0 +1,48 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div>
<div>
<h6>{{ t('mail', 'Operator') }}</h6>
</div>
<div>
<NcCheckboxRadioSwitch :checked="filter.operator"
value="allof"
name="sharing_permission_radio"
type="radio"
@update:checked="updateFilter('allof')">
allof ({{ t('mail', 'If all tests pass, then the actions will be executed') }})
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked="filter.operator"
value="anyof"
name="sharing_permission_radio"
type="radio"
@update:checked="updateFilter('anyof')">
anyof ({{ t('mail', 'If one test pass, then the actions will be executed') }})
</NcCheckboxRadioSwitch>
</div>
</div>
</template>
<script>
import { NcCheckboxRadioSwitch } from '@nextcloud/vue'
export default {
name: 'Operator',
components: {
NcCheckboxRadioSwitch,
},
props: {
filter: {
type: Object,
required: true,
},
},
methods: {
updateFilter(operator) {
this.$emit('update:operator', operator)
},
},
}
</script>

Просмотреть файл

@ -0,0 +1,120 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="mail-filter-rows">
<div class="mail-filter-rows__row">
<div class="mail-filter-rows__row__column">
<NcSelect input-label="field"
:value="test.field"
:required="true"
:label-outside="true"
:options="['subject', 'from', 'to']"
@input="updateTest({ field: $event })" />
</div>
<div class="mail-filter-rows__row__column">
<NcSelect input-label="operator"
:value="test.operator"
:required="true"
:label-outside="true"
:options="['is', 'contains', 'matches']"
@input="updateTest({ operator: $event })" />
</div>
<div class="mail-filter-rows__row__column">
<NcButton aria-label="Delete action"
type="tertiary-no-background"
@click="deleteTest">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ t('mail', 'Delete test') }}
</NcButton>
</div>
</div>
<div class="mail-filter-rows__row">
<NcSelect v-model="localValues"
input-label="value"
class="mail-filter-rows__row__select"
:multiple="true"
:wrap="true"
:close-on-select="false"
:taggable="true"
:required="true"
@option:selected="updateTest({ values: localValues })"
@option:deselected="updateTest({ values: localValues })" />
</div>
<hr class="solid">
</div>
</template>
<script>
import { NcButton, NcSelect } from '@nextcloud/vue'
import DeleteIcon from 'vue-material-design-icons/Delete.vue'
export default {
name: 'Test',
components: {
NcButton,
NcSelect,
DeleteIcon,
},
props: {
test: {
type: Object,
required: true,
},
},
data() {
return {
inputValue: '',
localValues: [],
}
},
mounted() {
this.localValues = [...this.test.values]
},
methods: {
updateTest(properties) {
this.$emit('update-test', { ...this.test, ...properties })
},
deleteTest() {
this.$emit('delete-test', this.test)
},
},
}
</script>
<style lang="scss" scoped>
.solid {
margin: calc(var(--default-grid-baseline) * 4) 0 0 0;
}
.mail-filter-rows {
margin-bottom: calc(var(--default-grid-baseline) * 4);
&__row {
display: flex;
gap: var(--default-grid-baseline);
align-items: baseline;
&__column {
display: block;
flex-grow: 1;
}
&__column *{
width: 100%;
}
&__select {
max-width: 100% !important;
width: 100%;
}
}
}
.values-list {
display: flex;
gap: var(--default-grid-baseline);
flex-wrap: wrap;
&__item {
display: flex;
gap: var(--default-grid-baseline);
}
}
</style>

Просмотреть файл

@ -0,0 +1,213 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcModal size="large"
:close-on-click-outside="false"
:name="t('mail','Update mail filter')"
@close="closeModal">
<form class="modal__content" @submit.prevent="updateFilter">
<div class="filter-name">
<NcTextField :value.sync="clone.name"
:label="t('mail', 'Filter name')"
:required="true" />
</div>
<div class="filter-operator">
<Operator :filter="clone" @update:operator="updateOperator" />
</div>
<div class="filter-tests">
<h6>{{ t('mail', 'Tests') }}</h6>
<div class="help-text">
<p>
{{ 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:') }}
</p>
<p>
<strong>is</strong>: {{ t('mail', 'An exact match. The field must be identical to the provided value.') }}
</p>
<p>
<strong>contains</strong>: {{ t('mail', 'A substring match. The field matches if the provided value is contained within it. For example, "report" would match "port".') }}
</p>
<p>
<strong>matches</strong>: {{ 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".') }}
</p>
</div>
<Test v-for="test in clone.tests"
:key="test.id"
:test="test"
@update-test="updateTest"
@delete-test="deleteTest" />
<NcButton class="app-settings-button"
type="secondary"
:aria-label="t('mail', 'New test')"
@click="createTest">
{{ t('mail', 'New test') }}
</NcButton>
</div>
<div class="filter-actions">
<h6>{{ t('mail', 'Actions') }}</h6>
<div class="help-text">
<p>
{{ t('mail', 'Actions are triggered when the specified tests are true. The following actions are available:') }}
</p>
<p>
<strong>fileinto</strong>: {{ t('mail', 'Moves the message into a specified folder.') }}
</p>
<p>
<strong>addflag</strong>: {{ t('mail', 'Adds a flag to the message.') }}
</p>
<p>
<strong>stop</strong>: {{ t('mail', 'Halts the execution of the filter script. No further filters with will be processed after this action.') }}
</p>
</div>
<Action v-for="action in clone.actions"
:key="action.id"
:action="action"
:account="account"
@update-action="updateAction"
@delete-action="deleteAction" />
<NcButton class="app-settings-button"
type="secondary"
:aria-label="t('mail', 'New action')"
@click="createAction">
{{ t('mail', 'New action') }}
</NcButton>
</div>
<NcTextField :value.sync="clone.priority"
type="number"
:label="t('mail', 'Priority')"
:required="true" />
<NcCheckboxRadioSwitch :checked.sync="clone.enable" type="switch">
{{ t('mail', 'Enable filter') }}
</NcCheckboxRadioSwitch>
<NcButton type="primary"
native-type="submit">
<template #icon>
<NcLoadingIcon v-if="loading" :size="16" />
<IconCheck v-else :size="16" />
</template>
{{ t('mail', 'Save filter') }}
</NcButton>
</form>
</NcModal>
</template>
<script>
import { NcButton, NcCheckboxRadioSwitch, NcModal, NcTextField, NcLoadingIcon } from '@nextcloud/vue'
import Test from './Test.vue'
import Operator from './Operator.vue'
import { randomId } from '../../util/randomId.js'
import Action from './Action.vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
export default {
name: 'UpdateModal',
components: {
IconCheck,
Action,
Operator,
Test,
NcButton,
NcCheckboxRadioSwitch,
NcLoadingIcon,
NcModal,
NcTextField,
},
props: {
filter: {
type: Object,
required: true,
},
account: {
type: Object,
required: true,
},
loading: {
type: Boolean,
required: false,
},
},
data() {
return {
clone: structuredClone(this.filter),
}
},
methods: {
createTest() {
this.clone.tests.push({ id: randomId(), operator: null, values: [] })
},
updateTest(test) {
const index = this.clone.tests.findIndex((items) => items.id === test.id)
this.$set(this.clone.tests, index, test)
},
deleteTest(test) {
this.clone.tests = this.clone.tests.filter((item) => item.id !== test.id)
},
createAction() {
this.clone.actions.push({ id: randomId(), type: null })
},
updateAction(action) {
const index = this.clone.actions.findIndex((item) => item.id === action.id)
this.$set(this.clone.actions, index, action)
},
updateOperator(operator) {
this.clone.operator = operator
},
deleteAction(action) {
this.clone.actions = this.clone.actions.filter((item) => item.id !== action.id)
},
updateFilter() {
this.$emit('update-filter', structuredClone(this.clone))
},
closeModal() {
this.$emit('close')
},
},
}
</script>
<style lang="scss" scoped>
.modal__content {
margin: 50px;
}
.modal__content h2 {
text-align: center;
}
.filter-name, .filter-operator, .filter-tests, .filter-actions {
margin-bottom: 8px;
}
.form-group {
margin: calc(var(--default-grid-baseline) * 4) 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.external-label {
display: flex;
width: 100%;
margin-top: 1rem;
}
.external-label label {
padding-top: 7px;
padding-right: 14px;
white-space: nowrap;
}
.help-text p {
margin-bottom: 0.2em;
}
</style>

Просмотреть файл

@ -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
}

Просмотреть файл

@ -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)
},
},
})

Просмотреть файл

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Unit\Service;
use ChristophWurst\Nextcloud\Testing\TestCase;
use OCA\Mail\Db\Alias;
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Service\AliasesService;
use OCA\Mail\Service\AllowedRecipientsService;
use PHPUnit\Framework\MockObject\MockObject;
class AllowedRecipientsServiceTest extends TestCase {
private AliasesService&MockObject $aliasesService;
private AllowedRecipientsService $allowedRecipientsService;
protected function setUp(): void {
parent::setUp();
$this->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]);
}
}

Просмотреть файл

@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Unit\Service;
use ChristophWurst\Nextcloud\Testing\TestCase;
use OCA\Mail\Db\MailAccount;
use OCA\Mail\IMAP\ImapFlag;
use OCA\Mail\Service\AllowedRecipientsService;
use OCA\Mail\Service\FilterService;
use OCA\Mail\Service\MailFilter\FilterBuilder;
use OCA\Mail\Service\MailFilter\FilterParser;
use OCA\Mail\Service\OutOfOffice\OutOfOfficeParser;
use OCA\Mail\Service\SieveService;
use OCA\Mail\Sieve\NamedSieveScript;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Psr\Log\Test\TestLogger;
class FilterServiceTest extends TestCase {
private string $testFolder;
private AllowedRecipientsService $allowedRecipientsService;
private OutOfOfficeParser $outOfOfficeParser;
private FilterParser $filterParser;
private FilterBuilder $filterBuilder;
private SieveService&MockObject $sieveService;
private LoggerInterface $logger;
private FilterService $filterService;
public function __construct(?string $name = null, array $data = [], $dataName = '') {
parent::__construct($name, $data, $dataName);
$this->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);
}
}

Просмотреть файл

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail\Tests\Unit\Service\MailFilter;
use ChristophWurst\Nextcloud\Testing\TestCase;
use OCA\Mail\IMAP\ImapFlag;
use OCA\Mail\Service\MailFilter\FilterBuilder;
class FilterBuilderTest extends TestCase {
private FilterBuilder $builder;
private string $testFolder;
public function __construct(?string $name = null, array $data = [], $dataName = '') {
parent::__construct($name, $data, $dataName);
$this->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;
}
}

Просмотреть файл

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Unit\Service\MailFilter;
use ChristophWurst\Nextcloud\Testing\TestCase;
use OCA\Mail\Service\MailFilter\FilterParser;
class FilterParserTest extends TestCase {
private FilterParser $filterParser;
private string $testFolder;
public function __construct(?string $name = null, array $data = [], $dataName = '') {
parent::__construct($name, $data, $dataName);
$this->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']);
}
}

Просмотреть файл

@ -124,11 +124,11 @@ class OutOfOfficeServiceTest extends TestCase {
['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 @@ class OutOfOfficeServiceTest extends TestCase {
['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');

Просмотреть файл

@ -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
}
]

Просмотреть файл

@ -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 ###

Просмотреть файл

@ -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"
}
]

Просмотреть файл

@ -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 ###

Просмотреть файл

@ -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
}
]

Просмотреть файл

@ -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 ###

Просмотреть файл

@ -0,0 +1,15 @@
[
{
"actions": [
{
"flag": "Flag 123",
"type": "addflag"
}
],
"enable": true,
"name": "Test 4",
"operator": "allof",
"priority": 60,
"tests": []
}
]

Просмотреть файл

@ -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 ###

Просмотреть файл

@ -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"
]
}
]
}
]

Просмотреть файл

@ -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 ###

Просмотреть файл

@ -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"
]
}
]
}
]

Просмотреть файл

@ -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 ###

Просмотреть файл

@ -0,0 +1 @@
[]

Просмотреть файл

@ -0,0 +1 @@
# Hello, this is a test

Просмотреть файл

@ -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 ###

Просмотреть файл

@ -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 ###

Просмотреть файл

@ -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@test.org>", "Test Alias <alias@test.org>"] "I'm on vacation.";
}
### Nextcloud Mail: Vacation Responder ### DON'T EDIT ###

Просмотреть файл

@ -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@test.org>", "Test Alias <alias@test.org>"] "I'm on vacation.";
}
### Nextcloud Mail: Vacation Responder ### DON'T EDIT ###

Просмотреть файл

@ -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 ###

Просмотреть файл

@ -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 ###

Просмотреть файл

@ -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
}
]

Просмотреть файл

@ -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 ###

Просмотреть файл

@ -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 ###

Просмотреть файл

@ -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
}
]

Просмотреть файл

@ -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 ###

Просмотреть файл

@ -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 ###