diff --git a/appinfo/routes.php b/appinfo/routes.php index 5812cb9d76..07dda222ef 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -113,6 +113,11 @@ 'url' => '/proxy', 'verb' => 'GET' ], + [ + 'name' => 'sieve#setActiveScript', + 'url' => '/api/accounts/{accountId}/scripts/active', + 'verb' => 'POST' + ], ], 'resources' => [ 'accounts' => ['url' => '/api/accounts'], @@ -122,5 +127,6 @@ 'localAttachments' => ['url' => '/api/attachments'], 'messages' => ['url' => '/api/accounts/{accountId}/folders/{folderId}/messages'], 'preferences' => ['url' => '/api/preferences'], + 'sieve' => ['url' => '/api/accounts/{accountId}/scripts'], ] ]; diff --git a/composer.json b/composer.json index c5720d0a5a..1e552c1d5d 100644 --- a/composer.json +++ b/composer.json @@ -29,11 +29,13 @@ "pear-pear.horde.org/horde_text_flowed": "^2.0.3@stable", "pear-pear.horde.org/horde_util": "^2.5.8@stable", "pear-pear.horde.org/horde_smtp": "^1.9.5@stable", + "pear-pear.horde.org/horde_managesieve": "^1.0.2@stable", "cerdic/css-tidy": "v1.7.1", "ezyang/htmlpurifier": "4.11.0", "kwi/urllinker": "dev-bleeding", "gravatarphp/gravatar": "^2.0", - "arthurhoaro/favicon": "^1.2" + "arthurhoaro/favicon": "^1.2", + "protonlabs/libsieve-php": "^2.0" }, "require-dev": { "christophwurst/nextcloud": "v17.0.0-beta3", diff --git a/composer.lock b/composer.lock index 2641a9a49e..49693d78c3 100644 --- a/composer.lock +++ b/composer.lock @@ -471,6 +471,36 @@ ], "description": "Provides interfaces for sending e-mail messages and parsing e-mail addresses." }, + { + "name": "pear-pear.horde.org/Horde_ManageSieve", + "version": "1.0.2", + "dist": { + "type": "file", + "url": "https://pear.horde.org/get/Horde_ManageSieve-1.0.2.tgz" + }, + "require": { + "pear-pear.horde.org/horde_exception": "<3.0.0.0", + "pear-pear.horde.org/horde_socket_client": "<3.0.0.0", + "pear-pear.horde.org/horde_util": "<3.0.0.0", + "php": ">=5.4.0.0" + }, + "replace": { + "pear-horde/horde_managesieve": "== 1.0.2.0" + }, + "type": "pear-library", + "autoload": { + "classmap": [ + "" + ] + }, + "include-path": [ + "/" + ], + "license": [ + "BSD" + ], + "description": "This library implements the ManageSieve protocol (RFC 5804)." + }, { "name": "pear-pear.horde.org/Horde_Mime", "version": "2.11.0", @@ -852,6 +882,70 @@ "LGPL-2.1" ], "description": "These classes provide functionality useful for all kind of applications." + }, + { + "name": "protonlabs/libsieve-php", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/ProtonMail/libsieve-php.git", + "reference": "b568743d701db534e15b0a3ef378e82162c74a16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ProtonMail/libsieve-php/zipball/b568743d701db534e15b0a3ef378e82162c74a16", + "reference": "b568743d701db534e15b0a3ef378e82162c74a16", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-simplexml": "*", + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^7", + "protonlabs/php-coding-standard": "^1.0", + "squizlabs/php_codesniffer": "^3.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Sieve\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-or-later" + ], + "authors": [ + { + "name": "Heiko Hund", + "homepage": "https://sourceforge.net/u/heikoh/profile/" + }, + { + "name": "Martin Zeman", + "email": "martin@protonmail.com", + "homepage": "https://protonmail.com" + }, + { + "name": "Kay Lukas", + "email": "kay@pm.me", + "homepage": "https://protonmail.com" + }, + { + "name": "Thomas Hareau", + "email": "thomas.hareau@protonmail.com", + "homepage": "https://thomas.hareau.eu" + } + ], + "description": "libsieve-php is a library to manage and modify sieve (RFC5228) scripts.", + "homepage": "https://sourceforge.net/projects/libsieve-php/", + "keywords": [ + "filters", + "mail", + "sieve" + ], + "time": "2019-02-08T20:05:14+00:00" } ], "packages-dev": [ @@ -3122,6 +3216,7 @@ "pear-pear.horde.org/horde_text_flowed": 0, "pear-pear.horde.org/horde_util": 0, "pear-pear.horde.org/horde_smtp": 0, + "pear-pear.horde.org/horde_managesieve": 0, "kwi/urllinker": 20, "christophwurst/nextcloud": 10 }, diff --git a/lib/Account.php b/lib/Account.php index 67295b639a..db2903ff54 100644 --- a/lib/Account.php +++ b/lib/Account.php @@ -33,6 +33,7 @@ namespace OCA\Mail; +use Horde\ManageSieve; use Horde_Imap_Client_Mailbox; use Horde_Imap_Client_Socket; use Horde_Mail_Rfc822_List; @@ -55,7 +56,10 @@ class Account implements JsonSerializable { private $account; /** @var Horde_Imap_Client_Socket */ - private $client; + private $imapClient; + + /** @var ManageSieve */ + private $sieveClient; /** @var ICrypto */ private $crypto; @@ -114,9 +118,10 @@ public function getEMailAddress() { /** * @return Horde_Imap_Client_Socket + * @throws \Horde_Imap_Client_Exception */ public function getImapConnection() { - if (is_null($this->client)) { + if (is_null($this->imapClient)) { $host = $this->account->getInboundHost(); $user = $this->account->getInboundUser(); $password = $this->account->getInboundPassword(); @@ -144,10 +149,44 @@ public function getImapConnection() { ))]; } } - $this->client = new \Horde_Imap_Client_Socket($params); - $this->client->login(); + $this->imapClient = new \Horde_Imap_Client_Socket($params); + $this->imapClient->login(); } - return $this->client; + return $this->imapClient; + } + + /** + * @return ManageSieve + * @throws ManageSieve\Exception + */ + public function getSieveConnection() + { + if ($this->sieveClient === null) { + $host = $this->account->getSieveHost(); + $user = $this->account->getSieveUser(); + $password = $this->account->getSievePassword(); + $password = $this->crypto->decrypt($password); + $port = $this->account->getSievePort(); + $ssl_mode = $this->convertSslMode($this->account->getSieveSslMode()); + + $params = [ + 'host' => $host, + 'port' => $port, + 'user' => $user, + 'password' => $password, + 'secure' => $ssl_mode, + ]; + + // TODO: configure sieve logging + /*if ($this->config->getSystemValue('debug', false)) { + $params['logger'] = $this->config->getSystemValue('datadirectory') . '/horde_sieve.log'; + }*/ + + $this->sieveClient = new ManageSieve($params); + + } + + return $this->sieveClient; } /** @@ -201,6 +240,11 @@ public function testConnectivity(Horde_Mail_Transport $transport) { if ($transport instanceof Horde_Mail_Transport_Smtphorde) { $transport->getSMTPObject(); } + + // connect to sieve + if ($this->account->getSieveHost() !== null) { + $this->getSieveConnection(); + } } /** diff --git a/lib/Command/CreateAccount.php b/lib/Command/CreateAccount.php index 010b5b6460..9241065cf0 100644 --- a/lib/Command/CreateAccount.php +++ b/lib/Command/CreateAccount.php @@ -46,6 +46,11 @@ class CreateAccount extends Command { const ARGUMENT_SMTP_SSL_MODE = 'smtp-ssl-mode'; const ARGUMENT_SMTP_USER = 'smtp-user'; const ARGUMENT_SMTP_PASSWORD = 'smtp-password'; + const ARGUMENT_SIEVE_HOST = 'sieve-host'; + const ARGUMENT_SIEVE_PORT = 'sieve-port'; + const ARGUMENT_SIEVE_SSL_MODE = 'sieve-ssl-mode'; + const ARGUMENT_SIEVE_USER = 'sieve-user'; + const ARGUMENT_SIEVE_PASSWORD = 'sieve-password'; /** @var AccountService */ private $accountService; @@ -78,6 +83,12 @@ protected function configure() { $this->addArgument(self::ARGUMENT_SMTP_SSL_MODE, InputArgument::REQUIRED); $this->addArgument(self::ARGUMENT_SMTP_USER, InputArgument::REQUIRED); $this->addArgument(self::ARGUMENT_SMTP_PASSWORD, InputArgument::REQUIRED); + + $this->addArgument(self::ARGUMENT_SIEVE_HOST, InputArgument::OPTIONAL); + $this->addArgument(self::ARGUMENT_SIEVE_PORT, InputArgument::OPTIONAL); + $this->addArgument(self::ARGUMENT_SIEVE_SSL_MODE, InputArgument::OPTIONAL); + $this->addArgument(self::ARGUMENT_SIEVE_USER, InputArgument::OPTIONAL); + $this->addArgument(self::ARGUMENT_SIEVE_PASSWORD, InputArgument::OPTIONAL); } protected function execute(InputInterface $input, OutputInterface $output) { @@ -97,6 +108,12 @@ protected function execute(InputInterface $input, OutputInterface $output) { $smtpUser = $input->getArgument(self::ARGUMENT_SMTP_USER); $smtpPassword = $input->getArgument(self::ARGUMENT_SMTP_PASSWORD); + $sieveHost = $input->getArgument(self::ARGUMENT_SIEVE_HOST); + $sievePort = $input->getArgument(self::ARGUMENT_SIEVE_PORT); + $sieveSslMode = $input->getArgument(self::ARGUMENT_SIEVE_SSL_MODE); + $sieveUser = $input->getArgument(self::ARGUMENT_SIEVE_USER); + $sievePassword = $input->getArgument(self::ARGUMENT_SIEVE_PASSWORD); + $account = new MailAccount(); $account->setUserId($userId); $account->setName($name); @@ -114,6 +131,14 @@ protected function execute(InputInterface $input, OutputInterface $output) { $account->setOutboundUser($smtpUser); $account->setOutboundPassword($this->crypto->encrypt($smtpPassword)); + if ($sieveHost !== null) { + $account->setSieveHost($sieveHost); + $account->setSievePort($sievePort); + $account->setSieveSslMode($sieveSslMode); + $account->setSieveUser($sieveUser); + $account->setSievePassword($this->crypto->encrypt($sievePassword)); + } + $this->accountService->save($account); $output->writeln("Account $email created"); diff --git a/lib/Command/ExportAccount.php b/lib/Command/ExportAccount.php index ce5e4ca2fd..3200b23e57 100644 --- a/lib/Command/ExportAccount.php +++ b/lib/Command/ExportAccount.php @@ -64,6 +64,11 @@ protected function execute(InputInterface $input, OutputInterface $output) { $output->writeln("- IMAP host: " . $account->getMailAccount()->getInboundHost() . ":" . $account->getMailAccount()->getInboundPort() . ", security: " . $account->getMailAccount()->getInboundSslMode()); $output->writeln("- SMTP user: " . $account->getMailAccount()->getOutboundUser()); $output->writeln("- SMTP host: " . $account->getMailAccount()->getOutboundHost() . ":" . $account->getMailAccount()->getOutboundPort() . ", security: " . $account->getMailAccount()->getOutboundSslMode()); + + if ($account->getMailAccount()->getSieveHost()) { + $output->writeln("- Sieve user: " . $account->getMailAccount()->getSieveUser()); + $output->writeln("- Sieve host: " . $account->getMailAccount()->getSieveHost() . ":" . $account->getMailAccount()->getSievePort() . ", security: " . $account->getMailAccount()->getSieveSslMode()); + } } } diff --git a/lib/Controller/AccountsController.php b/lib/Controller/AccountsController.php index 61fee9a7fd..16e8acc091 100644 --- a/lib/Controller/AccountsController.php +++ b/lib/Controller/AccountsController.php @@ -135,6 +135,7 @@ public function show($accountId): JSONResponse { * @param string $smtpSslMode * @param string $smtpUser * @param string $smtpPassword + * @param array|null $sieveConfig * @param bool $autoDetect * * @return JSONResponse @@ -154,14 +155,22 @@ public function update(int $id, int $smtpPort = null, string $smtpSslMode = null, string $smtpUser = null, - string $smtpPassword = null): JSONResponse { + string $smtpPassword = null, + array $sieveConfig = null): JSONResponse { $account = null; $errorMessage = null; try { if ($autoDetect) { $account = $this->setup->createNewAutoConfiguredAccount($accountName, $emailAddress, $password); } else { - $account = $this->setup->createNewAccount($accountName, $emailAddress, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->currentUserId, $id); + + $account = $this->setup->createNewAccount( + $accountName, $emailAddress, + $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, + $smtpHost,$smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, + $sieveConfig, + $this->currentUserId, $id + ); } } catch (Exception $ex) { $errorMessage = $ex->getMessage(); @@ -249,19 +258,40 @@ public function destroy($id): JSONResponse { * @param string $smtpSslMode * @param string $smtpUser * @param string $smtpPassword + * @param array|null $sieveConfig * @param bool $autoDetect * * @return JSONResponse * @throws ClientException */ - public function create(string $accountName, string $emailAddress, string $password = null, string $imapHost = null, int $imapPort = null, string $imapSslMode = null, string $imapUser = null, string $imapPassword = null, string $smtpHost = null, int $smtpPort = null, string $smtpSslMode = null, string $smtpUser = null, string $smtpPassword = null, bool $autoDetect = true): JSONResponse { + public function create(string $accountName, + string $emailAddress, + string $password = null, + string $imapHost = null, + int $imapPort = null, + string $imapSslMode = null, + string $imapUser = null, + string $imapPassword = null, + string $smtpHost = null, + int $smtpPort = null, + string $smtpSslMode = null, + string $smtpUser = null, + string $smtpPassword = null, + array $sieveConfig = null, + bool $autoDetect = true): JSONResponse { $account = null; $errorMessage = null; try { if ($autoDetect) { $account = $this->setup->createNewAutoConfiguredAccount($accountName, $emailAddress, $password); } else { - $account = $this->setup->createNewAccount($accountName, $emailAddress, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->currentUserId); + $account = $this->setup->createNewAccount( + $accountName, $emailAddress, + $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, + $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, + $sieveConfig, + $this->currentUserId + ); } } catch (Exception $ex) { $errorMessage = $ex->getMessage(); diff --git a/lib/Controller/SieveController.php b/lib/Controller/SieveController.php new file mode 100644 index 0000000000..5afba6fe9f --- /dev/null +++ b/lib/Controller/SieveController.php @@ -0,0 +1,119 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Controller; + +use OCA\Mail\Http\JSONResponse; +use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\SieveService; +use OCA\Mail\Service\Sieve\Script; +use OCP\AppFramework\Controller; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; + +class SieveController extends Controller +{ + + /** @var IUser */ + private $currentUser; + + /** @var SieveService */ + private $sieveService; + + /** @var AccountService */ + private $accountService; + + public function __construct($appName, IRequest $request, IUserSession $userSession, + SieveService $filtersService, AccountService $accountService) + { + parent::__construct($appName, $request); + + $this->currentUser = $userSession->getUser(); + $this->sieveService = $filtersService; + $this->accountService = $accountService; + + } + + /** + * @param int $accountId + * @return JSONResponse + * @throws AccountException + * @throws \Horde\ManageSieve\Exception + */ + public function index(int $accountId): JSONResponse + { + return new JSONResponse($this->getSieveService($accountId)->getScriptNames()); + } + + /** + * @param int $accountId + * @param string $script + * @param string $script_name + * @return JSONResponse + * @throws \Horde\ManageSieve\Exception + */ + public function create(int $accountId, string $script, string $script_name = null): JSONResponse + { + return new JSONResponse( + $this->getSieveService($accountId)->createScript($script, $script_name) + ); + } + + /** + * @param int $accountId + * @param string $id + * @return JSONResponse + * @throws AccountException + * @throws \Horde\ManageSieve\Exception + */ + public function show(int $accountId, string $id): JSONResponse + { + return new JSONResponse($this->getSieveService($accountId)->getScript($id)); + } + + /** + * @param int $accountId + * @param string $scriptName + * @return JSONResponse + * @throws AccountException + * @throws \Horde\ManageSieve\Exception + */ + public function setActiveScript(int $accountId, string $scriptName): JSONResponse + { + return new JSONResponse($this->getSieveService($accountId)->setActiveScript($scriptName)); + } + + /** + * @param int $accountId + * @return SieveService + * @throws AccountException + * @throws \Horde\ManageSieve\Exception + */ + private function getSieveService(int $accountId): SieveService + { + $account = $this->accountService->find($this->currentUser->getUID(), $accountId); + + return $this->sieveService->setAccount($account); + } +} diff --git a/lib/Db/MailAccount.php b/lib/Db/MailAccount.php index 1de82e3566..ec3762c30b 100644 --- a/lib/Db/MailAccount.php +++ b/lib/Db/MailAccount.php @@ -57,6 +57,16 @@ * @method void setOutboundUser(string $outboundUser) * @method string getOutboundPassword() * @method void setOutboundPassword(string $outboundPassword) + * @method string getSieveHost() + * @method void setSieveHost(string|null $sieveHost) + * @method integer getSievePort() + * @method void setSievePort(integer|null $sievePort) + * @method string getSieveSslMode() + * @method void setSieveSslMode(string|null $sieveSslMode) + * @method string getSieveUser() + * @method void setSieveUser(string|null $sieveUser) + * @method string getSievePassword() + * @method void setSievePassword(string $sievePassword) * @method string|null getSignature() * @method void setSignature(string|null $signature) * @method int getLastMailboxSync() @@ -79,6 +89,11 @@ class MailAccount extends Entity { protected $outboundSslMode; protected $outboundUser; protected $outboundPassword; + protected $sieveHost; + protected $sievePort; + protected $sieveSslMode; + protected $sieveUser; + protected $sievePassword; protected $signature; protected $lastMailboxSync; protected $editorMode; @@ -130,6 +145,22 @@ public function __construct(array $params=[]) { $this->setOutboundPassword($params['smtpPassword']); } + if (isset($params['sieveHost'])) { + $this->setSieveHost($params['sieveHost']); + } + if (isset($params['sievePort'])) { + $this->setSievePort($params['sievePort']); + } + if (isset($params['sieveSslMode'])) { + $this->setSieveSslMode($params['sieveSslMode']); + } + if (isset($params['sieveUser'])) { + $this->setSieveUser($params['sieveUser']); + } + if (isset($params['sievePassword'])) { + $this->setSievePassword($params['sievePassword']); + } + $this->addType('lastMailboxSync', 'integer'); } @@ -156,6 +187,13 @@ public function toJson() { $result['smtpSslMode'] = $this->getOutboundSslMode(); } + if ($this->getSieveHost() !== null) { + $result['sieveHost'] = $this->getSieveHost(); + $result['sievePort'] = $this->getSievePort(); + $result['sieveUser'] = $this->getSieveUser(); + $result['sieveSslMode'] = $this->getSieveSslMode(); + } + return $result; } } diff --git a/lib/Migration/Version0151Date20190622040212.php b/lib/Migration/Version0151Date20190622040212.php new file mode 100644 index 0000000000..941e6a24ea --- /dev/null +++ b/lib/Migration/Version0151Date20190622040212.php @@ -0,0 +1,56 @@ +hasTable('mail_accounts')) { + $table = $schema->getTable('mail_accounts'); + + $table->addColumn('sieve_host', Type::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + + $table->addColumn('sieve_port', Type::STRING, [ + 'notnull' => false, + 'length' => 6, + ]); + + $table->addColumn('sieve_ssl_mode', Type::STRING, [ + 'notnull' => false, + 'length' => 10, + ]); + + $table->addColumn('sieve_user', Type::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + + $table->addColumn('sieve_password', Type::STRING, [ + 'notnull' => false, + 'length' => 2048, + ]); + } + + return $schema; + } +} diff --git a/lib/Service/AutoConfig/MxRecord.php b/lib/Service/AutoConfig/MxRecord.php index 209c5787a2..e916137aa1 100644 --- a/lib/Service/AutoConfig/MxRecord.php +++ b/lib/Service/AutoConfig/MxRecord.php @@ -39,7 +39,10 @@ public function __construct(ILogger $logger) { * @return bool|array */ public function query(string $host) { - if (getmxrr($host, $mxRecords, $mxWeight) === false) { + if ( + getmxrr($host, $mxRecords, $mxWeight) === false || + $mxRecords[0] === '' + ) { $this->logger->debug("no MX records for host <$host> found"); return false; } diff --git a/lib/Service/DefaultAccount/Config.php b/lib/Service/DefaultAccount/Config.php index 4a95929a28..c5b116825d 100644 --- a/lib/Service/DefaultAccount/Config.php +++ b/lib/Service/DefaultAccount/Config.php @@ -107,6 +107,39 @@ public function getSmtpSslMode() { return $this->data['smtpSslMode']; } + /** + * @return string + */ + public function getSieveHost() { + return $this->data['sieveHost']; + } + + /** + * @return string|int + */ + public function getSievePort() { + return $this->data['sievePort']; + } + + /** + * @param IUser $user + * @return string|null + */ + public function buildSieveUser(IUser $user) { + if (isset($this->data['sieveUser'])) { + return $this->buildUserEmail($this->data['sieveUser'], $user); + } + + return null; + } + + /** + * @return string + */ + public function getSieveSslMode() { + return $this->data['sieveSslMode']; + } + /** * Replace %USERID% and %EMAIL% to allow special configurations * diff --git a/lib/Service/DefaultAccount/Manager.php b/lib/Service/DefaultAccount/Manager.php index a188e0fdbd..90d1815544 100644 --- a/lib/Service/DefaultAccount/Manager.php +++ b/lib/Service/DefaultAccount/Manager.php @@ -121,6 +121,12 @@ public function getDefaultAccount() { $account->setOutboundSslMode($config->getSmtpSslMode()); $account->setOutboundPassword($password); + $account->setSieveUser($config->buildSieveUser($user)); + $account->setSieveHost($config->getSieveHost()); + $account->setSievePort($config->getSievePort()); + $account->setSieveSslMode($config->getSieveSslMode()); + $account->setSievePassword($account->getSieveHost() ? $password : null); + return $account; } diff --git a/lib/Service/SetupService.php b/lib/Service/SetupService.php index 1fa5ba8eea..05ed3406b0 100644 --- a/lib/Service/SetupService.php +++ b/lib/Service/SetupService.php @@ -93,9 +93,16 @@ public function createNewAutoConfiguredAccount($accountName, $emailAddress, $pas * @param string $smtpSslMode * @param string $smtpUser * @param string $smtpPassword + * @param array|null $sieveConfig * @param string $uid + * @param null $accountId + * @return null|Account */ - public function createNewAccount($accountName, $emailAddress, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $uid, $accountId = null) { + public function createNewAccount($accountName, $emailAddress, + $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, + $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, + $sieveConfig, + $uid, $accountId = null) { $this->logger->info('Setting up manually configured account'); $newAccount = new MailAccount([ 'accountId' => $accountId, @@ -110,12 +117,20 @@ public function createNewAccount($accountName, $emailAddress, $imapHost, $imapPo 'smtpPort' => $smtpPort, 'smtpSslMode' => $smtpSslMode, 'smtpUser' => $smtpUser, - 'smtpPassword' => $smtpPassword + 'smtpPassword' => $smtpPassword, ]); $newAccount->setUserId($uid); $newAccount->setInboundPassword($this->crypto->encrypt($newAccount->getInboundPassword())); $newAccount->setOutboundPassword($this->crypto->encrypt($newAccount->getOutboundPassword())); + if ($sieveConfig !== null) { + $newAccount->setSieveHost($sieveConfig['host']); + $newAccount->setSievePort($sieveConfig['port']); + $newAccount->setSieveSslMode($sieveConfig['sslMode']); + $newAccount->setSieveUser($sieveConfig['user']); + $newAccount->setSievePassword($this->crypto->encrypt($sieveConfig['password'])); + } + $account = new Account($newAccount); $this->logger->debug('Connecting to account {account}', ['account' => $newAccount->getEmail()]); $transport = $this->smtpClientFactory->create($account); diff --git a/lib/Service/Sieve/Script.php b/lib/Service/Sieve/Script.php new file mode 100644 index 0000000000..668182379c --- /dev/null +++ b/lib/Service/Sieve/Script.php @@ -0,0 +1,100 @@ + + * + * @author 2019 Pierre Gordon + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Service\Sieve; + +use Sieve\SieveException; +use Sieve\SieveParser; + +class Script +{ + + public const SCRIPT_NAME = 'nextcloud'; + public const SCRIPT_CUSTOM_BASE_NAME = 'nextcloud.filter.'; + public const TYPE_CUSTOM = 'custom'; + public const TYPE_SIMPLE = 'simple'; + + /** @var string */ + private $name; + + /** @var string */ + private $script; + + /** @var string */ + private $parseError; + + public function __construct(string $name, string $script) + { + $this->name = $name; + $this->script = $script; + $this->parseError = ''; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return string + */ + public function getScript(): string + { + return $this->script; + } + + /** + * @return string + */ + public function getParseError(): string + { + return $this->parseError; + } + + /** + * @return int + */ + public function getSize(): int + { + return strlen($this->script); + } + + public function isValid(): bool + { + $parser = new SieveParser(); + + try { + $parser->parse($this->script); + return true; + } catch (SieveException $e) { + $this->parseError = $e->getMessage(); + return false; + } + } +} diff --git a/lib/Service/Sieve/ScriptFactory.php b/lib/Service/Sieve/ScriptFactory.php new file mode 100644 index 0000000000..875b32ef84 --- /dev/null +++ b/lib/Service/Sieve/ScriptFactory.php @@ -0,0 +1,47 @@ + + * + * @author 2019 Pierre Gordon + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Mail\Service\Sieve; + +class ScriptFactory { + + /** + * @param string $script + * @param string $script_name + * @return Script + */ + public function createCustom(string $script, string $script_name): Script { + return new Script(Script::SCRIPT_CUSTOM_BASE_NAME . $script_name, $script); + } + + /** + * @param string $script + * @return Script + */ + public function createSimple(string $script): Script { + return new Script(Script::SCRIPT_NAME, $script); + } +} diff --git a/lib/Service/SieveService.php b/lib/Service/SieveService.php new file mode 100644 index 0000000000..b191f97a38 --- /dev/null +++ b/lib/Service/SieveService.php @@ -0,0 +1,151 @@ + + * + * Mail + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Mail\Service; + +use Horde\ManageSieve; +use OCA\Mail\Account; +use OCA\Mail\Service\Sieve\Script; +use OCA\Mail\Service\Sieve\ScriptFactory; + +class SieveService +{ + + /** @var ManageSieve */ + private $sieveClient; + + /** @var ScriptFactory */ + private $scriptFactory; + + /** + * FiltersService constructor. + * @param ScriptFactory $scriptFactory + */ + public function __construct(ScriptFactory $scriptFactory) + { + $this->scriptFactory = $scriptFactory; + } + + /** + * @param Account $account + * @return $this + * @throws ManageSieve\Exception + */ + public function setAccount(Account $account): SieveService + { + $this->sieveClient = $account->getSieveConnection(); + return $this; + } + + /** + * @return array + */ + public function getScriptNames(): array + { + $scriptNames = $this->sieveClient->listScripts(); + $activeScriptName = $this->sieveClient->getActive(); + + $scripts = array_fill_keys(['active', 'entries'], null); + + foreach ($scriptNames as $scriptName) { + if ($scriptName === $activeScriptName) { + $scripts['active'] = $scriptName; + } + + $scripts['entries'][] = $scriptName; + } + + return compact('scripts'); + } + + /** + * @param string $scriptName + * @return array + * @throws ManageSieve\Exception + */ + public function setActiveScript(string $scriptName): array + { + $isActive = false; + $scriptNames = $this->sieveClient->listScripts(); + $activeScriptName = $this->sieveClient->getActive(); + + if ($scriptName !== $activeScriptName && in_array($scriptName, $scriptNames)) { + $this->sieveClient->setActive($scriptName); + $isActive = true; + } + + return compact('isActive'); + } + + /** + * @param string $scriptName + * @return array + * @throws ManageSieve\Exception + */ + public function getScript(string $scriptName): array + { + return ['script' => $this->sieveClient->getScript($scriptName)]; + } + + /** + * @param string $filter + * @param string $script_name + * @return array + * @throws ManageSieve\Exception + */ + public function createScript(string $filter, ?string $script_name): array + { + if ($script_name !== null) { + $script = $this->scriptFactory->createCustom($filter, $script_name); + return $this->installRawScript($script); + } + + return ['status' => 'error', 'message' => 'Not implemented']; + } + + /** + * @param Script $script + * @return array + * @throws ManageSieve\Exception + */ + private function installRawScript(Script $script): array + { + if ($script->isValid()) { + $this->installScript($script); + return ['status' => 'success']; + } else { + return ['status' => 'error', 'message' => $script->getParseError()]; + } + } + + /** + * @param Script $script + * @throws ManageSieve\Exception + */ + private function installScript(Script $script) + { + if ($this->sieveClient->hasSpace($script->getName(), $script->getSize())) { + $this->sieveClient->installScript($script->getName(), $script->getScript(), true); + } + } +} diff --git a/package.json b/package.json index eadfbf342b..143a6f8a83 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "test:watch": "mochapack -w --webpack-config webpack.test.js --require src/tests/setup.js \"src/tests/**/*.spec.js\"" }, "dependencies": { + "@saeris/vue-spinners": "^1.0.8", + "@vue/babel-preset-app": "^3.11.0", "@bundle-analyzer/webpack-plugin": "^0.5.0", "@ckeditor/ckeditor5-basic-styles": "^11.1.4", "@ckeditor/ckeditor5-build-balloon": "^12.4.0", @@ -56,12 +58,15 @@ "vue-autosize": "^1.0.2", "vue-click-outside": "^1.0.7", "vue-infinite-scroll": "^2.0.2", + "vue-m-message": "^2.0.0", "vue-on-click-outside": "^1.0.3", "vue-router": "^3.1.3", "vue-scroll": "^2.1.12", + "vue-select": "^3.1.0", "vue-shortkey": "^3.1.7", "vue-slide-up-down": "^2.0.0", "vue-tabs-component": "^1.5.0", + "vuedraggable": "^2.23.0", "vuex": "^3.1.1", "vuex-router-sync": "^5.0.0" }, diff --git a/src/components/AccountForm.vue b/src/components/AccountForm.vue index ec56627d10..5dfd10b40b 100644 --- a/src/components/AccountForm.vue +++ b/src/components/AccountForm.vue @@ -227,6 +227,14 @@ /> + +
+ + +
+ + + @@ -235,10 +243,12 @@ import {Tab, Tabs} from 'vue-tabs-component' import Logger from '../logger' +import SieveForm from './SieveForm' export default { name: 'AccountForm', components: { + SieveForm, Tab, Tabs, }, @@ -263,7 +273,7 @@ export default { }, data() { const fromAccountOr = (prop, def) => { - if (this.account !== undefined) { + if (this.account !== undefined && this.account.hasOwnProperty(prop)) { return this.account[prop] } else { return def @@ -273,6 +283,7 @@ export default { return { loading: false, mode: 'auto', + sieveEnabled: false, autoConfig: { accountName: this.displayName, emailAddress: this.email, @@ -292,14 +303,36 @@ export default { smtpUser: fromAccountOr('smtpUser', ''), smtpPassword: '', }, + sieveConfig: { + host: fromAccountOr('sieveHost', ''), + port: fromAccountOr('sievePort', 4190), + sslMode: fromAccountOr('sieveSslMode', 'tls'), + user: fromAccountOr('sieveUser', ''), + password: '', + }, submitButtonText: this.account ? t('mail', 'Save') : t('mail', 'Connect'), } }, + created() { + this.sieveEnabled = this.sieveConfig.host !== '' + }, computed: { settingsPage() { return this.account !== undefined }, }, + watch: { + sieveEnabled(enabled) { + if (enabled) { + if (this.sieveConfig.user === '') { + this.sieveConfig.user = this.autoConfig.emailAddress + } + if (this.sieveConfig.password === '') { + this.sieveConfig.password = this.autoConfig.password + } + } + }, + }, methods: { onModeChanged(e) { this.mode = e.tab.id @@ -352,17 +385,12 @@ export default { } }, saveChanges() { - if (this.mode === 'auto') { - return this.save({ - autoDetect: true, - ...this.autoConfig, - }) - } else { - return this.save({ - autoDetect: false, - ...this.manualConfig, - }) - } + const autoDetect = this.mode === 'auto' + const config = autoDetect ? this.autoConfig : this.manualConfig + + config.sieveConfig = this.sieveEnabled ? this.sieveConfig : null + + return this.save({autoDetect, ...config}) }, onSubmit: function() { this.loading = true @@ -394,39 +422,44 @@ export default { padding-top: 20px; } -.tabs-component-panels label { +#account-form input, +#account-form select { + margin-bottom: 10px; +} + +#account-form label { text-align: left; width: 100%; display: inline-block; } -.tabs-component-panels input, -.tabs-component-panels select { - margin-bottom: 10px; -} - - - + + diff --git a/src/components/AppSettingsMenu.vue b/src/components/AppSettingsMenu.vue index bc2fc156cb..9cfcd37bcb 100644 --- a/src/components/AppSettingsMenu.vue +++ b/src/components/AppSettingsMenu.vue @@ -23,6 +23,12 @@

+

+ + {{ t('mail', 'Filter') }} + +

+

{{ t('mail', 'Looking for a way to encrypt your emails? Install the Mailvelope browser extension!') diff --git a/src/components/FilterSettings.vue b/src/components/FilterSettings.vue new file mode 100644 index 0000000000..1c0f9f0fb7 --- /dev/null +++ b/src/components/FilterSettings.vue @@ -0,0 +1,165 @@ + + + diff --git a/src/components/SieveActions.vue b/src/components/SieveActions.vue new file mode 100644 index 0000000000..e6af4fcdcc --- /dev/null +++ b/src/components/SieveActions.vue @@ -0,0 +1,113 @@ + + + + \ No newline at end of file diff --git a/src/components/SieveFilterSets.vue b/src/components/SieveFilterSets.vue new file mode 100644 index 0000000000..28b44d60dd --- /dev/null +++ b/src/components/SieveFilterSets.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/src/components/SieveFilters.vue b/src/components/SieveFilters.vue new file mode 100644 index 0000000000..d2463a28e6 --- /dev/null +++ b/src/components/SieveFilters.vue @@ -0,0 +1,201 @@ + + + + + + diff --git a/src/components/SieveFiltersRaw.vue b/src/components/SieveFiltersRaw.vue new file mode 100644 index 0000000000..1f88782ff5 --- /dev/null +++ b/src/components/SieveFiltersRaw.vue @@ -0,0 +1,96 @@ + + + + diff --git a/src/components/SieveForm.vue b/src/components/SieveForm.vue new file mode 100644 index 0000000000..d05d2568f9 --- /dev/null +++ b/src/components/SieveForm.vue @@ -0,0 +1,76 @@ + + + diff --git a/src/components/SieveTests.vue b/src/components/SieveTests.vue new file mode 100644 index 0000000000..e71cfa406d --- /dev/null +++ b/src/components/SieveTests.vue @@ -0,0 +1,128 @@ + + + + \ No newline at end of file diff --git a/src/router.js b/src/router.js index 7544bcb3a4..cfa15421be 100644 --- a/src/router.js +++ b/src/router.js @@ -6,6 +6,7 @@ const AccountSettings = () => import('./views/AccountSettings') const Home = () => import('./views/Home') const KeyboardShortcuts = () => import('./views/KeyboardShortcuts') const Setup = () => import('./views/Setup') +const SieveView = () => import('./views/SieveView') Vue.use(Router) @@ -48,5 +49,10 @@ export default new Router({ name: 'setup', component: Setup, }, + { + path: '/filter', + name: 'filter', + component: SieveView, + }, ], }) diff --git a/src/service/FiltersService.js b/src/service/FiltersService.js new file mode 100644 index 0000000000..f306756bf8 --- /dev/null +++ b/src/service/FiltersService.js @@ -0,0 +1,570 @@ +/** + * @copyright Copyright (c) 2019, Merlin Mittelbach + * + * @author 2019, Merlin Mittelbach + * @author Pierre Gordon + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +import {generateUrl} from 'nextcloud-router' +import HttpClient from 'nextcloud-axios' + +const generateScriptsUrl = (accountId, path = null) => { + const url = `/apps/mail/api/accounts/{id}/scripts${path ? `/${path}` : ''}` + + return generateUrl(url, {id: accountId}) +} + +export const getScripts = accountId => { + const url = generateScriptsUrl(accountId) + + return HttpClient.get(url).then(resp => resp.data.scripts) +} + +export const getScript = (accountId, scriptName) => { + const url = generateScriptsUrl(accountId, scriptName) + + return HttpClient.get(url).then(resp => resp.data.script) +} + +export const setActiveScript = (accountId, scriptName) => { + const url = generateScriptsUrl(accountId, 'active') + + return HttpClient.post(url, {scriptName}).then(resp => resp.data.isActive) +} + +export const saveCustomScript = (accountId, script) => { + const url = generateScriptsUrl(accountId) + return HttpClient.post(url, {script}).then(resp => resp.data) +} + +export const ParseSieveError = function(message) { + this.message = message +} + +export const escapeSieve = function(string) { + return string + // if already escaped unescape it + .replace(/\\\\/g, "\\") + .replace(/\\"/g, "\"") + // then escape + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") +} + +/* +register matchtype here +key: [string] sieve matchtype value +props: + name: [string] matchtype name (required) + req: [string] requirement to be imported to sieve script (not required) +*/ +export const matchTypeBlueprint = { + ":is": { + "name": t("mail", "is"), + }, + ":contains": { + "name": t("mail", "contains"), + }, + ":matches": { + "name": t("mail", "matches"), + }, + ":regex": { + "req": "regex", + "name": t("mail", "matches regular expression"), + }, +} + +/* +register test here +key: [string] sieve test id +props: + name: [string] test name + req: [string] requirement to be imported to sieve script + matchTypes: [array of matchTypes] all possible matchTypes to this test + opts_default: [object] default test options + make: method to create sieve script + input: test object (name, opts) + output: req = array of requirements + script = rendered sieve test string + parse: method to parse sieve script + input: test array e.g.: 'not header ":is" "to" "test"'' -> ["not","header",":is","to","test"] + output: if test array is recognised: new test object + else: undefined +*/ +export const sieveTestsBlueprint = { + "subject": { + "name": t("mail", "Subject"), + "matchTypes": [":is", ":contains", ":matches", ":regex"], + "opts_default": { + "negate": false, + "matchType": ":is", + "value": "", + }, + "make": function(test) { + let reqs = new Set([]) + if (matchTypeBlueprint[test.opts.matchType].req !== undefined) { + reqs.add(req) + } + const negate = test.opts.negate ? "not " : "" + const escapedValue = escapeSieve(test.opts.value) + let script = `${negate}header ${test.opts.matchType} \"subject\" \"${escapedValue}\"` + return {reqs, script} + }, + "parse": function(test) { + let out = { + "type": "subject", + "opts": { + "negate": false, + "matchType": ":is", + "value": "", + } + } + if (test[0].toLowerCase() === "not") { + out.opts.negate = true + test.shift() + } + if (test[0].toLowerCase() === "header" && test[2].toLowerCase() === "subject") { + if (this.matchTypes.indexOf(test[1]) > -1) { + out.opts.matchType = test[1] + out.opts.value = test[3] + return out + } else { + throw new ParseSieveError(`matchtype not supported: '${test[1]}'`) + } + } else { + return undefined + } + }, + }, + "from": { + "name": t("mail", "From"), + "matchTypes": [":is", ":contains", ":matches", ":regex"], + "opts_default": { + "negate": false, + "matchType": ":is", + "value": "", + }, + "make": function(test) { + let reqs = new Set([]) + if (matchTypeBlueprint[test.opts.matchType].req !== undefined) { + reqs.add(matchTypeBlueprint[test.opts.matchType].req) + } + const negate = test.opts.negate ? "not " : "" + const escapedValue = escapeSieve(test.opts.value) + let script = `${negate}header ${test.opts.matchType} \"from\" \"${escapedValue}\"` + return {reqs, script} + }, + "parse": function(test) { + let out = { + "type": "from", + "opts": { + "negate": false, + "matchType": ":is", + "value": "", + } + } + if (test[0].toLowerCase() === "not") { + out.opts.negate = true + test.shift() + } + if (test[0].toLowerCase() === "header" && test[2].toLowerCase() === "from") { + if (this.matchTypes.indexOf(test[1]) > -1) { + out.opts.matchType = test[1] + out.opts.value = test[3] + return out + } else { + throw new ParseSieveError(`matchtype not supported: '${test[1]}'`) + } + } else { + return undefined + } + }, + }, + "to": { + "name": t("mail", "To"), + "matchTypes": [":is", ":contains", ":matches", ":regex"], + "opts_default": { + "negate": false, + "matchType": ":is", + "value": "", + }, + "make": function(test) { + let reqs = new Set([]) + if (matchTypeBlueprint[test.opts.matchType].req !== undefined) { + reqs.add(matchTypeBlueprint[test.opts.matchType].req) + } + const negate = test.opts.negate ? "not " : "" + const escapedValue = escapeSieve(test.opts.value) + let script = `${negate}header ${test.opts.matchType} \"to\" \"${escapedValue}\"` + return {reqs, script} + }, + "parse": function(test) { + let out = { + "type": "to", + "opts": { + "negate": false, + "matchType": ":is", + "value": "", + } + } + if (test[0].toLowerCase() === "not") { + out.opts.negate = true + test.shift() + } + if (test[0].toLowerCase() === "header" && test[2].toLowerCase() === "to") { + if (this.matchTypes.indexOf(test[1]) > -1) { + out.opts.matchType = test[1] + out.opts.value = test[3] + return out + } else { + throw new ParseSieveError(`matchtype not supported: '${test[1]}'`) + } + } else { + return undefined + } + }, + }, + "exists": { + "name": t("mail", "Header exists"), + "opts_default": { + "negate": false, + "value": "", + }, + "make": function(test) { + let reqs = new Set([]) + const negate = test.opts.negate ? "not " : "" + const escapedValue = escapeSieve(test.opts.value) + let script = `${negate}exists \"${escapedValue}\"` + return {reqs, script} + }, + "parse": function(test) { + let out = { + "type": "exists", + "opts": { + "negate": false, + "value": "", + } + } + if (test[0].toLowerCase() === "not") { + out.opts.negate = true + test.shift() + } + if (test[0].toLowerCase() === "exists") { + out.opts.value = test[1] + return out + } else { + return undefined + } + }, + }, + "content": { + "name": t("mail", "Content"), + "matchTypes": [":is", ":contains", ":matches", ":regex"], + "req": "body", + "opts_default": { + "negate": false, + "matchType": ":is", + "value": "", + }, + "make": function(test) { + let reqs = new Set([this.req]) + if (matchTypeBlueprint[test.opts.matchType].req !== undefined) { + reqs.add(matchTypeBlueprint[test.opts.matchType].req) + } + const negate = test.opts.negate ? "not " : "" + const escapedValue = escapeSieve(test.opts.value) + let script = `${negate}body :text ${test.opts.matchType} \"${escapedValue}\"` + return {reqs, script} + }, + "parse": function(test) { + let out = { + "type": "content", + "opts": { + "negate": false, + "matchType": ":is", + "value": "", + } + } + if (test[0].toLowerCase() === "not") { + out.opts.negate = true + test.shift() + } + if (test[0].toLowerCase() === "body" && test.indexOf(":text") > -1) { + const matchTypeIndex = test.indexOf(":text") === 1 ? 2 : 1 + if (this.matchTypes.indexOf(test[matchTypeIndex]) > -1) { + out.opts.matchType = test[matchTypeIndex] + out.opts.value = test[3] + return out + } else { + throw new ParseSieveError(`matchtype not supported: '${test[1]}'`) + } + } else { + return undefined + } + }, + }, +} +/* +register action here +key: [string] sieve action id +props: + name: [string] action name + req: [string] requirement to be imported to sieve script + opts_default: [object] default action options + make: method to create sieve script + input: action object (name, opts) + output: req = array of requirements + script = rendered sieve action string + parse: method to parse sieve script + input: action array e.g.: 'fileinto :copy "INBOX"' -> ["fileinto", ":copy", "INBOX"] + output: if test array is recognised: new test object + else: undefined +*/ +export const sieveActionsBlueprint = { + "move": { + "name": t("mail", "Move mail into"), + "opts_default": { + "value": "SU5CT1g=", + }, + "make": function(action) { + let reqs = ["fileinto"] + let folder = atob(action.opts.value) + let script = `fileinto ${folder};` + return {reqs, script} + }, + "parse": function(action) { + let out = { + "type": "move", + "opts": { + "value": btoa(action[1]), + } + } + if (action[0].toLowerCase() === "fileinto" && action.length === 2) { + return out + } else { + return undefined + } + }, + }, + "copy": { + "name": t("mail", "Copy mail into"), + "opts_default": { + "value": "SU5CT1g=", + }, + "make": function(action) { + let reqs = ["fileinto", "copy"] + let folder = atob(action.opts.value) + let script = `fileinto :copy ${folder};` + return {reqs, script} + }, + "parse": function(action) { + let out = { + "type": "copy", + "opts": { + "value": btoa(action[2]), + } + } + if (action[0].toLowerCase() === "fileinto" && action[1] === ":copy" && action.length === 3) { + return out + } else { + return undefined + } + }, + }, +} + +export const makeSieveScript = function(filters){ + if (filters === undefined) { + return "" + } else { + let raw = "" + let reqSet = new Set() + for (const filter of filters) { + let tests = [] + for (const test of filter.tests.list){ + const {reqs, script} = sieveTestsBlueprint[test.type].make(test) + tests.push(script) + for (const req of reqs) { + reqSet.add(req) + } + } + let actions = [] + for (const action of filter.actions) { + const {reqs, script} = sieveActionsBlueprint[action.type].make(action) + actions.push(script) + for (const req of reqs) { + reqSet.add(req) + } + } + + tests = tests.join(", ") + actions = actions.join("\n\t") + + raw += +` +# rule:[${filter.name}] +if ${filter.tests.type}(${tests}) +{ + ${actions} +} +` + } + const reqs = Array.from(reqSet).join("\",\"") + if (reqs === "") { + return `${raw}` + } else { + return `require [\"${reqs}\"]; +${raw}` + } + } +} + +export const parseSieveScript = (raw) => { + if (raw === "" || raw === undefined) { + return [] + } else { + let filters = [] + let match + + // throw away require list + const requireRegex = /require[\s\S]*?;\s*/gm + raw = raw.replace(requireRegex,"") + + let strings = {} + let counter = 0 + // separate multiline strings + const multilineRegex = /text:\s*?(.[\s\S]*?)^\.\n/gm + match = multilineRegex.exec(raw) + while (match !== null) { + strings[`__${counter}__`] = match[1] + raw = raw.replace(match[0], `__${counter}__`) + counter += 1 + match = multilineRegex.exec(raw) + } + // separate one line strings + const matches = raw.match(/"[^"\\]*(?:\\.[^"\\]*)*"/gm) + if (matches !== null) { + for (const match of matches) { + raw = raw.replace(match, `__${counter}__`) + strings[`__${counter}__`] = match.slice(1,-1) + counter += 1 + } + } + console.log + // unescape strings + for (const key of Object.keys(strings)) { + strings[key] = strings[key] + .replace(/\\"/g, "\"") + .replace(/\\\\/g, "\\") + } + + // throw away comments + const commentRegex = /#(?!\s+rule:).*|\/\*.*\*\/|\/\*(.*\n)+\*\//gm + raw = raw.replace(commentRegex,"") + + // get commands/filters + const commandRegex = /(?:# rule:\[(\w+)\]\s*)?^(\w+)\s*([^;]*?)\s*\{\s*([^\}]*?)\s*\}\s*/gm + match = commandRegex.exec(raw) + let newFilterID = 0 + while (match !== null) { + const [ , name, command_type, testsArray, actionsRaw] = match + let filter = { + "id": newFilterID, + "name": name !== undefined ? name : "Filter_"+newFilterID, + "tests": { + "type": "allof", + "list": [], + }, + "actions": [], /*[{ + "id": 0, + "type": "move", + "opts": JSON.parse(JSON.stringify(sieveActionsBlueprint["move"].opts_default)) + }],*/ + } + + if (command_type === "if") { + + // parse tests + const testsArrayRegex = /(allof|anyof)?\s*\(?\s*([\s\S]*.)\s*\)/gm + const testMatch = testsArrayRegex.exec(testsArray) + let tests + if (testMatch === null) { + tests = [ testsArray ] + } else { + filter.tests.type = testMatch[1] + tests = testMatch[2].split(/\s*,\s*/) + } + let newTestID = 0 + for (let test of tests) { + test = test.split(/\s+/) + let newTest + for (const testType in sieveTestsBlueprint) { + if (newTest === undefined){ + newTest = sieveTestsBlueprint[testType].parse(test.map(x => strings[x] === undefined ? x : strings[x])) + } + } + if (newTest === undefined) { + let error = test.map(x => strings[x] === undefined ? x : strings[x]) + error = error.join(" ") + throw new ParseSieveError(`test couldn't be interpreted: '${error}'`) + } else { + newTest["id"] = newTestID + newTestID += 1 + } + filter.tests.list.push(newTest) + } + + // parse actions + const actions = actionsRaw.split(/;\s*/).filter(x => x !== "") + let newActionID = 0 + for (let action of actions) { + action = action.split(/\s+/) + let newAction + for (const actionType in sieveActionsBlueprint) { + if (newAction === undefined){ + newAction = sieveActionsBlueprint[actionType].parse(action.map(x => strings[x] === undefined ? x : strings[x])) + } + } + if (newAction === undefined) { + let error = action.map(x => strings[x] === undefined ? x : strings[x]) + error = error.join(" ") + throw new ParseSieveError(`action couldn't be interpreted: '${error}'`) + } else { + newAction["id"] = newActionID + newActionID += 1 + } + filter.actions.push(newAction) + } + } else { + throw new ParseSieveError(`command type not supported: '${type}'`) + } + + match = commandRegex.exec(raw) + newFilterID += 1 + filters.push(filter) + } + raw = raw.replace(commandRegex, "") + // if anything left parse error + if (raw.match(/\S/gm) !== null) { + let error = raw.replace(/\n/gm, " ") + throw new ParseSieveError(`unknown script part: '${error}'`) + } else { + return filters + } + } +} \ No newline at end of file diff --git a/src/store/actions.js b/src/store/actions.js index b9a5ce870a..3ae16f86ae 100644 --- a/src/store/actions.js +++ b/src/store/actions.js @@ -2,6 +2,7 @@ * @copyright 2019 Christoph Wurst * * @author 2019 Christoph Wurst + * @author 2019, Merlin Mittelbach * * @license GNU AGPL version 3 or any later version * @@ -35,6 +36,7 @@ import {fetchAll as fetchAllFolders, create as createFolder, markFolderRead} fro import {deleteMessage, fetchEnvelopes, fetchMessage, setEnvelopeFlag, syncEnvelopes} from '../service/MessageService' import {showNewMessagesNotification} from '../service/NotificationService' import {parseUid} from '../util/EnvelopeUidParser' +import {getScripts, getScript} from '../service/FiltersService' export default { savePreference({commit, getters}, {key, value}) { @@ -494,4 +496,58 @@ export default { throw err }) }, + async fetchSieveScripts({getters, commit}, accountID){ + commit("setSieveLoading", true) + commit("resetFilterAccount", {accountID}) + let scripts = await getScripts(accountID) + if (scripts.entries !== null) { + scripts.entries = scripts.entries.filter(x => x !== "nextcloud_includes") + if (scripts.active === "nextcloud_includes") { + const includes_raw = await getScript(accountID, "nextcloud_includes") + const includeRegex = /include\s*:personal\s*"(\S*)";/gm + let match = includeRegex.exec(includes_raw) + while (match !== null) { + if (scripts.entries.indexOf(match[1]) !== -1) { + const raw = await getScript(accountID, match[1]) + commit("newFilterSet", { + "accountID": accountID, + "name": match[1], + "original_name": match[1], + "raw": raw, + "active": true, + "changed": false, + }) + } + scripts.entries = scripts.entries.filter(x => x !== match[1]) + match = includeRegex.exec(includes_raw) + } + } else { + if (scripts.active !== null) { + const raw = await getScript(accountID, scripts.active) + commit("newFilterSet", { + "accountID": accountID, + "name": scripts.active, + "original_name": scripts.active, + "raw": raw, + "active": true, + "changed": false, + }) + scripts.entries = scripts.entries.filter(x => x !== scripts.active) + } + } + for (const script of scripts.entries) { + const raw = await getScript(accountID, script) + commit("newFilterSet", { + "accountID": accountID, + "name": script, + "original_name": script, + "raw": raw, + "changed": false, + }) + } + commit("setSieveLoading", false) + } else { + commit("setSieveLoading", false) + } + } } diff --git a/src/store/constants.js b/src/store/constants.js index a3c8222f3a..87c22988e9 100644 --- a/src/store/constants.js +++ b/src/store/constants.js @@ -22,3 +22,6 @@ export const UNIFIED_ACCOUNT_ID = 0 export const UNIFIED_INBOX_ID = btoa('inbox') export const UNIFIED_INBOX_UID = UNIFIED_ACCOUNT_ID + '-' + UNIFIED_INBOX_ID + +export const SIEVE_NAME = 'nextcloud' +export const SIEVE_CUSTOM_NAME = 'nextcloud.custom' diff --git a/src/store/index.js b/src/store/index.js index 4dc434babc..196cf7c437 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2,6 +2,7 @@ * @copyright 2019 Christoph Wurst * * @author 2019 Christoph Wurst + * @author 2019, Merlin Mittelbach * * @license GNU AGPL version 3 or any later version * @@ -78,6 +79,22 @@ export const getters = { getMessageByUid: state => uid => { return state.messages[uid] }, + sieveAccountList: state => (accountID) => { + return state.accountList.map(function(accountID) { + if (state.accounts[accountID].sieveHost != undefined) { + return accountID + } + }).filter(accountID => accountID !== undefined) + }, + getFilterSetByID: state => (accountID, filterSetID) => { + return state.sieveFilterSets[accountID].find(x => x.id === filterSetID) + }, + getFilterSetByName: state => (accountID, name) => { + return state.sieveFilterSets[accountID].find(x => x.name === name) + }, + getFilterByID: state => (accountID, filterSetID, filterID) => { + return state.sieveFilters[accountID][filterSetID].find(x => x.id === filterID) + }, } export default new Vuex.Store({ @@ -112,6 +129,10 @@ export default new Vuex.Store({ envelopes: {}, messages: {}, autocompleteEntries: [], + sieveFilterSets: {}, + sieveFilters: {}, + sieveLoading: true, + sieveDelete: new Set(), }, getters, mutations, diff --git a/src/store/mutations.js b/src/store/mutations.js index beb9a7d18b..f40abeadaf 100644 --- a/src/store/mutations.js +++ b/src/store/mutations.js @@ -2,6 +2,7 @@ * @copyright 2019 Christoph Wurst * * @author 2019 Christoph Wurst + * @author 2019, Merlin Mittelbach * * @license GNU AGPL version 3 or any later version * @@ -21,11 +22,13 @@ import _ from 'lodash' import Vue from 'vue' +import getters from './index.js' import {buildMailboxHierarchy} from '../imap/MailboxHierarchy' import {havePrefix} from '../imap/MailboxPrefix' import {sortMailboxes} from '../imap/MailboxSorter' import {UNIFIED_ACCOUNT_ID} from './constants' +import {ParseSieveError,parseSieveScript} from '../service/FiltersService' const addFolderToState = (state, account) => folder => { const id = account.id + '-' + folder.id @@ -200,4 +203,116 @@ export default { removeMessage(state, {accountId, folderId, id}) { Vue.delete(state.messages, accountId + '-' + folderId + '-' + id) }, + newFilter(state, {accountID, filterSetID, filter}){ + if (state.sieveFilters[accountID] === undefined) { + Vue.set(state.sieveFilters, accountID, {}) + } + if (state.sieveFilters[accountID][filterSetID] === undefined) { + Vue.set(state.sieveFilters[accountID], filterSetID, []) + } + state.sieveFilters[accountID][filterSetID].push(filter) + state.sieveFilterSets[accountID][filterSetID].changed = true + }, + newFilterSet(state, {accountID, name, raw, original_name, active = false, changed = true}) { + let newID = 0 + if (state.sieveFilterSets[accountID] === undefined) { + Vue.set(state.sieveFilterSets, accountID, []) + } + if (state.sieveFilterSets[accountID] !== undefined){ + while (state.sieveFilterSets[accountID].find(x => x.id === newID) !== undefined){ + newID += 1 + } + } else { + state.sieveFilterSets[accountID] = [] + } + if (name === undefined) { + name = "FilterSet_"+newID; + let counter = 0; + if (state.sieveFilterSets[accountID].find(x => x.name === name) !== undefined){ + while (state.sieveFilterSets[accountID].find(x => x.name === name+"_"+counter) !== undefined) { + counter += 1; + } + name = name+"_"+counter + } + } + try { + let filters = parseSieveScript(raw) + state.sieveFilterSets[accountID].push({ + "id": newID, + "name": name, + "original_name": original_name, + "parsed": true, + "active": active, + "changed": changed, + }) + if (state.sieveFilters[accountID] === undefined) { + Vue.set(state.sieveFilters, accountID, {}) + } + if (state.sieveFilters[accountID][newID] === undefined) { + Vue.set(state.sieveFilters[accountID], newID, filters) + } + } catch (e) { + if (e instanceof ParseSieveError) { + state.sieveFilterSets[accountID].push({ + "id": newID, + "name": name, + "parsed": false, + "raw": raw, + "active": active, + "parseError": e.message, + "changed": changed, + "original_name": original_name, + }) + } else { + throw e + } + } + }, + rmFilter(state, {accountID, filterSetID, filterID}){ + state.sieveFilters[accountID][filterSetID] = state.sieveFilters[accountID][filterSetID].filter(x => x.id !== filterID) + state.sieveFilterSets[accountID][filterSetID].changed = true + }, + rmFilters(state, {accountID, filterSetID}){ + Vue.set(state.sieveFilters[accountID], filterSetID, []) + }, + rmFilterSet(state, {accountID, filterSetID}){ + state.sieveDelete.add(state.sieveFilterSets[accountID][state.sieveFilterSets[accountID].findIndex(x => x.id === filterSetID)].name) + state.sieveFilterSets[accountID] = state.sieveFilterSets[accountID].filter(x => x.id !== filterSetID) + Vue.delete(state.sieveFilters[accountID], filterSetID) + }, + updateFilter(state, {accountID, filterSetID, filterID, filter}){ + state.sieveFilters[accountID][filterSetID][state.sieveFilters[accountID][filterSetID].findIndex(x => x.id === filterID)] = filter + state.sieveFilterSets[accountID][filterSetID].changed = true + }, + updateFilters(state, {accountID, filterSetID, value}){ + state.sieveFilters[accountID][filterSetID] = value + state.sieveFilterSets[accountID][filterSetID].changed = true + }, + updateFilterSet(state, {accountID, filterSetID, value}){ + Vue.set(state.sieveFilterSets[accountID], state.sieveFilterSets[accountID].findIndex(x => x.id === filterSetID), value) + }, + updateFilterSets(state, {accountID, value}){ + state.sieveFilterSets[accountID] = value + }, + updateFilterSetName(state, {accountID, filterSetID, name}){ + state.sieveDelete.add(state.sieveFilterSets[accountID][state.sieveFilterSets[accountID].findIndex(x => x.id === filterSetID)].name) + state.sieveFilterSets[accountID][state.sieveFilterSets[accountID].findIndex(x => x.id === filterSetID)].name = name + state.sieveFilterSets[accountID][filterSetID].changed = true + }, + updateRawSieveScript(state, {accountID, filterSetID, raw}) { + state.sieveFilterSets[accountID].find(x => x.id === filterSetID).raw = raw + state.sieveFilterSets[accountID][filterSetID].changed = true + }, + resetFilterAccount(state, {accountID}) { + Vue.delete(state.sieveFilterSets, accountID) + Vue.delete(state.sieveFilters, accountID) + }, + setSieveLoading(state, bool) { + state.sieveLoading = bool + state.sieveDelete = new Set() + }, + toggleActiveSieve(state, {accountID, filterSetID}) { + state.sieveFilterSets[accountID][state.sieveFilterSets[accountID].findIndex(x => x.id === filterSetID)].active = + !state.sieveFilterSets[accountID][state.sieveFilterSets[accountID].findIndex(x => x.id === filterSetID)].active + }, } diff --git a/src/views/AccountSettings.vue b/src/views/AccountSettings.vue index 839d330e81..ee623865d2 100644 --- a/src/views/AccountSettings.vue +++ b/src/views/AccountSettings.vue @@ -2,6 +2,9 @@ +

{{ t('mail', 'Account Settings') }} - {{ email }}

{{ t('mail', 'Mail server') }}

@@ -24,6 +27,7 @@ import EditorSettings from '../components/EditorSettings' import Logger from '../logger' import Navigation from '../components/Navigation' import SignatureSettings from '../components/SignatureSettings' +import FilterSettings from '../components/FilterSettings' export default { name: 'AccountSettings', @@ -34,6 +38,7 @@ export default { EditorSettings, Navigation, SignatureSettings, + FilterSettings, }, data() { const account = this.$store.getters.getAccount(this.$route.params.accountId) diff --git a/src/views/SieveView.vue b/src/views/SieveView.vue new file mode 100644 index 0000000000..7c15844519 --- /dev/null +++ b/src/views/SieveView.vue @@ -0,0 +1,293 @@ + + + + + + diff --git a/tests/Command/CreateAccountTest.php b/tests/Command/CreateAccountTest.php index b2bb24008c..4cf5ab102e 100644 --- a/tests/Command/CreateAccountTest.php +++ b/tests/Command/CreateAccountTest.php @@ -29,7 +29,7 @@ class CreateAccountTest extends TestCase { private $service; private $crypto; private $command; - private $args = [ + private $requiredArgs = [ 'user-id', 'name', 'email', @@ -44,6 +44,13 @@ class CreateAccountTest extends TestCase { 'smtp-user', 'smtp-password', ]; + private $optionalArgs = [ + 'sieve-host', + 'sieve-port', + 'sieve-ssl-mode', + 'sieve-user', + 'sieve-password', + ]; protected function setUp() { parent::setUp(); @@ -68,8 +75,8 @@ public function testArguments() { $actual = $this->command->getDefinition()->getArguments(); foreach ($actual as $actArg) { - $this->assertTrue($actArg->isRequired()); - $this->assertTrue(in_array($actArg->getName(), $this->args)); + $type = $actArg->isRequired() ? 'required' : 'optional'; + $this->assertTrue(in_array($actArg->getName(), $this->{"{$type}Args"})); } } diff --git a/tests/Controller/AccountsControllerTest.php b/tests/Controller/AccountsControllerTest.php index b5ef64b6a7..e81ac77017 100644 --- a/tests/Controller/AccountsControllerTest.php +++ b/tests/Controller/AccountsControllerTest.php @@ -222,7 +222,7 @@ public function testCreateAutoDetectSuccess() { ->willReturn($account); $response = $this->controller->create($accountName, $email, $password, null, null, null, null, null, null, null, null, - null, null, true); + null, null, null, true); $expectedResponse = new JSONResponse($account, Http::STATUS_CREATED); @@ -240,7 +240,7 @@ public function testCreateAutoDetectFailure() { $this->expectException(ClientException::class); $this->controller->create($accountName, $email, $password, null, null, null, null, null, null, null, null, - null, null, true); + null, null, null, true); } public function testUpdateAutoDetectSuccess() { @@ -254,7 +254,7 @@ public function testUpdateAutoDetectSuccess() { ->willReturn($account); $response = $this->controller->create($accountName, $email, $password, null, null, null, null, null, null, null, null, - null, null, true); + null, null, null, true); $expectedResponse = new JSONResponse($account, Http::STATUS_CREATED); @@ -272,7 +272,7 @@ public function testUpdateAutoDetectFailure() { $this->expectException(ClientException::class); $this->controller->create($accountName, $email, $password, null, null, null, null, null, null, null, null, - null, null, true); + null, null, null, true); } public function testCreateManualSuccess() { @@ -290,13 +290,14 @@ public function testCreateManualSuccess() { $smtpSslMode = 'none'; $smtpUser = 'user@domain.tld'; $smtpPassword = 'mypassword'; + $sieveConfig = null; $account = $this->createMock(Account::class); $this->setupService->expects($this->once()) ->method('createNewAccount') - ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId) + ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $sieveConfig, $this->userId) ->willReturn($account); - $response = $this->controller->create($accountName, $email, $password, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $autoDetect); + $response = $this->controller->create($accountName, $email, $password, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $sieveConfig, $autoDetect); $expectedResponse = new JSONResponse($account, Http::STATUS_CREATED); @@ -318,13 +319,14 @@ public function testCreateManualFailure() { $smtpSslMode = 'none'; $smtpUser = 'user@domain.tld'; $smtpPassword = 'mypassword'; + $sieveConfig = null; $this->setupService->expects($this->once()) ->method('createNewAccount') - ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId) + ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $sieveConfig, $this->userId) ->willThrowException(new Exception()); $this->expectException(ClientException::class); - $this->controller->create($accountName, $email, $password, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $autoDetect); + $this->controller->create($accountName, $email, $password, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $sieveConfig, $autoDetect); } public function testUpdateManualSuccess() { @@ -343,10 +345,11 @@ public function testUpdateManualSuccess() { $smtpSslMode = 'none'; $smtpUser = 'user@domain.tld'; $smtpPassword = 'mypassword'; + $sieveConfig = null; $account = $this->createMock(Account::class); $this->setupService->expects($this->once()) ->method('createNewAccount') - ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId, $id) + ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $sieveConfig, $this->userId, $id) ->willReturn($account); $response = $this->controller->update($id, $autoDetect, $accountName, $email, $password, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword); @@ -372,9 +375,10 @@ public function testUpdateManualFailure() { $smtpSslMode = 'none'; $smtpUser = 'user@domain.tld'; $smtpPassword = 'mypassword'; + $sieveConfig = null; $this->setupService->expects($this->once()) ->method('createNewAccount') - ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId, $id) + ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $sieveConfig, $this->userId, $id) ->willThrowException(new Exception()); $this->expectException(ClientException::class); diff --git a/tests/Service/DefaultAccount/ManagerTest.php b/tests/Service/DefaultAccount/ManagerTest.php index 96fd15c0b3..02de7af818 100644 --- a/tests/Service/DefaultAccount/ManagerTest.php +++ b/tests/Service/DefaultAccount/ManagerTest.php @@ -105,6 +105,9 @@ public function testGetDefaultAccount() { 'smtpHost' => 'smtp.domain.tld', 'smtpPort' => 465, 'smtpSslMode' => 'tls', + 'sieveHost' => null, + 'sievePort' => null, + 'sieveSslMode' => null, ]); $credentials = $this->createMock(ICredentials::class); $user = $this->createMock(IUser::class); @@ -145,6 +148,11 @@ public function testGetDefaultAccount() { $expected->setOutboundPort(465); $expected->setOutboundSslMode('tls'); $expected->setOutboundPassword('encrypted'); + $expected->setSieveUser(null); + $expected->setSieveHost(null); + $expected->setSievePort(null); + $expected->setSieveSslMode(null); + $expected->setSievePassword(null); $account = $this->manager->getDefaultAccount();