Skip to content

Commit

Permalink
feat: mail filters
Browse files Browse the repository at this point in the history
Co-authored-by: Hamza Mahjoubi <[email protected]>
Signed-off-by: Daniel Kesselberg <[email protected]>
  • Loading branch information
kesselb and hamza221 committed Oct 11, 2024
1 parent 8ddec6c commit 0981ed7
Show file tree
Hide file tree
Showing 56 changed files with 2,479 additions and 36 deletions.
6 changes: 6 additions & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
59 changes: 59 additions & 0 deletions lib/Controller/FilterController.php
Original file line number Diff line number Diff line change
@@ -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([]);
}
}
29 changes: 29 additions & 0 deletions lib/Exception/FilterParserException.php
Original file line number Diff line number Diff line change
@@ -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',
);
}
}
34 changes: 34 additions & 0 deletions lib/Service/AllowedRecipientsService.php
Original file line number Diff line number Diff line change
@@ -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);
}
}
88 changes: 88 additions & 0 deletions lib/Service/FilterService.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
165 changes: 165 additions & 0 deletions lib/Service/MailFilter/FilterBuilder.php
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading

0 comments on commit 0981ed7

Please sign in to comment.