Skip to content

Commit

Permalink
feat: mail filters
Browse files Browse the repository at this point in the history
Signed-off-by: Daniel Kesselberg <[email protected]>

feat: mail filter ui rework

Signed-off-by: Hamza Mahjoubi <[email protected]>
  • Loading branch information
kesselb committed Sep 18, 2024
1 parent d03ec29 commit 37cad8d
Show file tree
Hide file tree
Showing 40 changed files with 1,908 additions and 31 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
58 changes: 58 additions & 0 deletions lib/Controller/MailfilterController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?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\MailFilterService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\Route;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;

class MailfilterController extends OCSController {
private string $currentUserId;

public function __construct(IRequest $request,
string $userId,
private MailFilterService $mailFilterService,
private AccountService $accountService,
) {
parent::__construct(Application::APP_ID, $request);
$this->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([]);
}
}
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);
}
}
154 changes: 154 additions & 0 deletions lib/Service/MailFilter/FilterBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?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 = '# DATA: ';
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: ' . $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];
}

$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);
}
}
62 changes: 62 additions & 0 deletions lib/Service/MailFilter/FilterParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?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 = '# DATA: ';

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 {
$filters = json_decode($json, true, 10, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw FilterParserException::invalidJson($e);
}
}
break;
default:
throw FilterParserException::invalidState();
}
$state = $nextState;
}

return new FilterParserResult($filters, $sieveScript, implode("\r\n", $scriptOut));
}
}
43 changes: 43 additions & 0 deletions lib/Service/MailFilter/FilterParserResult.php
Original file line number Diff line number Diff line change
@@ -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(),
];
}
}
Loading

0 comments on commit 37cad8d

Please sign in to comment.