Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

mail filters #10135

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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',
);
}
}
21 changes: 21 additions & 0 deletions lib/Exception/ImapFlagEncodingException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?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 ImapFlagEncodingException extends Exception {
public static function create($label): ImapFlagEncodingException {
return new self(
'Failed to convert the given label "' . $label . '" to UTF7-IMAP',
0,
);
}
}
28 changes: 28 additions & 0 deletions lib/IMAP/ImapFlag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\IMAP;

use OCA\Mail\Exception\ImapFlagEncodingException;

class ImapFlag {
/**
* @throws ImapFlagEncodingException
*/
public function create(string $label): string {
$flag = str_replace(' ', '_', $label);
$flag = mb_convert_encoding($flag, 'UTF7-IMAP', 'UTF-8');

if ($flag === false) {
throw ImapFlagEncodingException::create($label);
}

return '$' . strtolower(mb_strcut($flag, 0, 63));
}
}
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 = '# 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];
}

$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);
}
}
Loading
Loading