diff options
author | Anna Larch <anna@nextcloud.com> | 2021-06-23 14:03:52 +0300 |
---|---|---|
committer | Anna Larch <anna@nextcloud.com> | 2021-08-24 13:41:14 +0300 |
commit | 2422e15270ab69b1da62f01231018c078738a66d (patch) | |
tree | 65bb81dd0153972c48f740dfb72f65e07b179872 | |
parent | 251d6f0d23a3dcaa41daaf84d12fd10a85095515 (diff) |
Create anti spam report feature
Signed-off-by: Anna Larch <anna@nextcloud.com>
21 files changed, 1246 insertions, 11 deletions
diff --git a/appinfo/routes.php b/appinfo/routes.php index ce6107c54..6242fe0e2 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -260,6 +260,16 @@ return [ 'verb' => 'DELETE' ], [ + 'name' => 'settings#setAntiSpamEmail', + 'url' => '/api/settings/antispam', + 'verb' => 'POST' + ], + [ + 'name' => 'settings#deleteAntiSpamEmail', + 'url' => '/api/settings/antispam', + 'verb' => 'DELETE' + ], + [ 'name' => 'trusted_senders#setTrusted', 'url' => '/api/trustedsenders/{email}', 'verb' => 'PUT' diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 65c7e68d2..3228834e5 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -45,6 +45,8 @@ use OCA\Mail\HordeTranslationHandler; use OCA\Mail\Http\Middleware\ErrorMiddleware; use OCA\Mail\Http\Middleware\ProvisioningMiddleware; use OCA\Mail\Listener\AddressCollectionListener; +use OCA\Mail\Listener\HamReportListener; +use OCA\Mail\Listener\SpamReportListener; use OCA\Mail\Listener\DeleteDraftListener; use OCA\Mail\Listener\FlagRepliedMessageListener; use OCA\Mail\Listener\InteractionListener; @@ -101,6 +103,8 @@ class Application extends App implements IBootstrap { $context->registerEventListener(DraftSavedEvent::class, DeleteDraftListener::class); $context->registerEventListener(MailboxesSynchronizedEvent::class, MailboxesSynchronizedSpecialMailboxesUpdater::class); $context->registerEventListener(MessageFlaggedEvent::class, MessageCacheUpdaterListener::class); + $context->registerEventListener(MessageFlaggedEvent::class, SpamReportListener::class); + $context->registerEventListener(MessageFlaggedEvent::class, HamReportListener::class); $context->registerEventListener(MessageDeletedEvent::class, MessageCacheUpdaterListener::class); $context->registerEventListener(MessageSentEvent::class, AddressCollectionListener::class); $context->registerEventListener(MessageSentEvent::class, DeleteDraftListener::class); diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index dd1a19dc4..d88705f23 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -28,6 +28,7 @@ namespace OCA\Mail\Controller; use OCA\Mail\AppInfo\Application; use OCA\Mail\Exception\ValidationException; use OCA\Mail\Http\JsonResponse as HttpJsonResponse; +use OCA\Mail\Service\AntiSpamService; use OCA\Mail\Service\Provisioning\Manager as ProvisioningManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\JSONResponse; @@ -39,10 +40,15 @@ class SettingsController extends Controller { /** @var ProvisioningManager */ private $provisioningManager; + /** @var AntiSpamService */ + private $antiSpamService; + public function __construct(IRequest $request, - ProvisioningManager $provisioningManager) { + ProvisioningManager $provisioningManager, + AntiSpamService $antiSpamService) { parent::__construct(Application::APP_ID, $request); $this->provisioningManager = $provisioningManager; + $this->antiSpamService = $antiSpamService; } public function index(): JSONResponse { @@ -88,4 +94,24 @@ class SettingsController extends Controller { return new JSONResponse([]); } + + /** + * + * @return JSONResponse + */ + public function setAntiSpamEmail(string $spam, string $ham): JSONResponse { + $this->antiSpamService->setSpamEmail($spam); + $this->antiSpamService->setHamEmail($ham); + return new JSONResponse([]); + } + + /** + * Store the credentials used for SMTP in the config + * + * @return JSONResponse + */ + public function deleteAntiSpamEmail(): JSONResponse { + $this->antiSpamService->deleteConfig(); + return new JSONResponse([]); + } } diff --git a/lib/Listener/HamReportListener.php b/lib/Listener/HamReportListener.php new file mode 100644 index 000000000..35590a994 --- /dev/null +++ b/lib/Listener/HamReportListener.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Anna Larch <anna@nextcloud.com> + * + * @author Anna Larch <anna@nextcloud.com> + * + * @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 <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Listener; + +use OCA\Mail\Events\MessageFlaggedEvent; +use OCA\Mail\Service\AntiSpamService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; + +class HamReportListener implements IEventListener { + + /** @var LoggerInterface */ + private $logger; + + /** @var AntiSpamService */ + private $antiSpamService; + + public function __construct(LoggerInterface $logger, + AntiSpamService $antiSpamService) { + $this->logger = $logger; + $this->antiSpamService = $antiSpamService; + } + + public function handle(Event $event): void { + if (!$event instanceof MessageFlaggedEvent || $event->getFlag() !== '$notjunk') { + return; + } + + if (!$event->isSet()) { + return; + } + + // Send message to reporting service + try { + $this->antiSpamService->sendReportEmail($event->getAccount(), $event->getMailbox(), $event->getUid(), $event->getFlag()); + } catch (\Throwable $e) { + $this->logger->error("Could not send spam report: " . $e->getMessage(), ['exception' => $e]); + } + } +} diff --git a/lib/Listener/SpamReportListener.php b/lib/Listener/SpamReportListener.php new file mode 100644 index 000000000..5188d3807 --- /dev/null +++ b/lib/Listener/SpamReportListener.php @@ -0,0 +1,64 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Anna Larch <anna@nextcloud.com> + * + * @author Anna Larch <anna@nextcloud.com> + * + * @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 <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Listener; + +use OCA\Mail\Events\MessageFlaggedEvent; +use OCA\Mail\Service\AntiSpamService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; + +class SpamReportListener implements IEventListener { + + /** @var LoggerInterface */ + private $logger; + + /** @var AntiSpamService */ + private $antiSpamService; + + public function __construct(LoggerInterface $logger, + AntiSpamService $antiSpamService) { + $this->logger = $logger; + $this->antiSpamService = $antiSpamService; + } + + public function handle(Event $event): void { + if (!$event instanceof MessageFlaggedEvent || $event->getFlag() !== '$junk') { + return; + } + + if (!$event->isSet()) { + return; + } + + // Send message to reporting service + try { + $this->antiSpamService->sendReportEmail($event->getAccount(), $event->getMailbox(), $event->getUid(), $event->getFlag()); + } catch (\Throwable $e) { + $this->logger->error("Could not send spam report: " . $e->getMessage(), ['exception' => $e]); + } + } +} diff --git a/lib/Model/IMAPMessage.php b/lib/Model/IMAPMessage.php index a3d38df18..0b3c38f50 100644 --- a/lib/Model/IMAPMessage.php +++ b/lib/Model/IMAPMessage.php @@ -652,6 +652,16 @@ class IMAPMessage implements IMessage, JsonSerializable { } /** + * @param string $name + * @param string $content + * + * @return void + */ + public function addEmbeddedMessageAttachment(string $name, string $content): void { + throw new Exception('IMAP message is immutable'); + } + + /** * @param File $file * * @return void diff --git a/lib/Model/IMessage.php b/lib/Model/IMessage.php index 74aa64916..7ee3108a2 100644 --- a/lib/Model/IMessage.php +++ b/lib/Model/IMessage.php @@ -127,11 +127,19 @@ interface IMessage { /** * @param string $name - * @param string $content + * @param string $content attached with mime type 'application/octet-stream' */ public function addRawAttachment(string $name, string $content): void; /** + * @param string $name + * @param string $content attached with mime type 'message/rfc822' + * + * @return void + */ + public function addEmbeddedMessageAttachment(string $name, string $content): void; + + /** * @param File $file */ public function addAttachmentFromFiles(File $file); diff --git a/lib/Model/Message.php b/lib/Model/Message.php index 37d231c4b..5466fbf2f 100644 --- a/lib/Model/Message.php +++ b/lib/Model/Message.php @@ -237,6 +237,23 @@ class Message implements IMessage { } /** + * @param string $name + * @param string $content + * + * @return void + */ + public function addEmbeddedMessageAttachment(string $name, string $content): void { + $mime = 'message/rfc822'; + $part = new Horde_Mime_Part(); + $part->setCharset('us-ascii'); + $part->setDisposition('attachment'); + $part->setName($name); + $part->setContents($content); + $part->setType($mime); + $this->attachments[] = $part; + } + + /** * @param File $file * * @return void diff --git a/lib/Service/AntiSpamService.php b/lib/Service/AntiSpamService.php new file mode 100644 index 000000000..f9292fe53 --- /dev/null +++ b/lib/Service/AntiSpamService.php @@ -0,0 +1,123 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Anna Larch <anna@nextcloud.com> + * + * @author Anna Larch <anna@nextcloud.com> + * + * 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 <http://www.gnu.org/licenses/> + * + */ + +namespace OCA\Mail\Service; + +use OCA\Mail\Account; +use OCA\Mail\Contracts\IMailTransmission; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\MessageMapper; +use OCA\Mail\Exception\SentMailboxNotSetException; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Model\NewMessageData; +use OCP\IConfig; + +class AntiSpamService { + private const NAME = 'antispam_reporting'; + private const MESSAGE_TYPE = 'message/rfc822'; + + /** @var IConfig */ + private $config; + + /** @var MessageMapper */ + private $messageMapper; + + /** @var IMailTransmission */ + private $transmission; + + public function __construct(IConfig $config, + MessageMapper $messageMapper, + IMailTransmission $transmission) { + $this->config = $config; + $this->messageMapper = $messageMapper; + $this->transmission = $transmission; + } + + public function getSpamEmail(): string { + return $this->config->getAppValue('mail', self::NAME . '_spam'); + } + + public function getHamEmail(): string { + return $this->config->getAppValue('mail', self::NAME. '_ham'); + } + + public function getSpamSubject(): string { + return 'Learn as Junk'; + } + + public function getHamSubject(): string { + return 'Learn as Not Junk'; + } + + public function setSpamEmail(string $email): void { + $this->config->setAppValue('mail', self::NAME . '_spam', $email); + } + + public function setHamEmail(string $email): void { + $this->config->setAppValue('mail', self::NAME. '_ham', $email); + } + + public function deleteConfig(): void { + $this->config->deleteAppValue('mail', self::NAME . '_spam'); + $this->config->deleteAppValue('mail', self::NAME . '_ham'); + } + + /** + * @param Account $account + * @param Mailbox $mailbox + * @param int $uid + * @param string $flag + * @throws ServiceException + */ + public function sendReportEmail(Account $account, Mailbox $mailbox, int $uid, string $flag): void { + $reportEmail = ($flag === '$junk') ? $this->getSpamEmail() : $this->getHamEmail(); + if ($reportEmail === '') { + return; + } + $subject = ($flag === '$junk') ? $this->getSpamSubject() : $this->getHamSubject(); + + // Message to attach not found + $messageId = $this->messageMapper->getIdForUid($mailbox, $uid); + if ($messageId === null) { + throw new ServiceException('Could not find reported message'); + } + + $messageData = NewMessageData::fromRequest( + $account, + $reportEmail, + null, + null, + $subject, + $subject, // add any message body - not all IMAP servers accept empty emails + [['id' => $messageId, 'type' => self::MESSAGE_TYPE]] + ); + + try { + $this->transmission->sendMessage($messageData); + } catch (SentMailboxNotSetException | ServiceException $e) { + throw new ServiceException('Could not send report email from anti spam email service', 0, $e); + } + } +} diff --git a/lib/Service/MailTransmission.php b/lib/Service/MailTransmission.php index ea31e5e2e..154cff9a0 100644 --- a/lib/Service/MailTransmission.php +++ b/lib/Service/MailTransmission.php @@ -327,6 +327,9 @@ class MailTransmission implements IMailTransmission { } elseif (isset($attachment['type']) && $attachment['type'] === 'message') { // Adds another message as attachment $this->handleForwardedMessageAttachment($account, $attachment, $message); + } elseif (isset($attachment['type']) && $attachment['type'] === 'message/rfc822') { + // Adds another message as attachment with mime type 'message/rfc822 + $this->handleEmbeddedMessageAttachments($account, $attachment, $message); } elseif (isset($attachment['type']) && $attachment['type'] === 'message-attachment') { // Adds an attachment from another email (use case is, eg., a mail forward) $this->handleForwardedAttachment($account, $attachment, $message); @@ -394,6 +397,32 @@ class MailTransmission implements IMailTransmission { * @param mixed[] $attachment * @param IMessage $message */ + private function handleEmbeddedMessageAttachments(Account $account, array $attachment, IMessage $message): void { + // Gets original of other message + $userId = $account->getMailAccount()->getUserId(); + $attachmentMessage = $this->mailManager->getMessage($userId, (int)$attachment['id']); + $mailbox = $this->mailManager->getMailbox($userId, $attachmentMessage->getMailboxId()); + + $fullText = $this->messageMapper->getFullText( + $this->imapClientFactory->getClient($account), + $mailbox->getName(), + $attachmentMessage->getUid() + ); + + $message->addEmbeddedMessageAttachment( + $attachment['displayName'] ?? $attachmentMessage->getSubject() . '.eml', + $fullText + ); + } + + + /** + * Adds an attachment that's coming from another message's attachment (typical use case: email forwarding) + * + * @param Account $account + * @param mixed[] $attachment + * @param IMessage $message + */ private function handleForwardedAttachment(Account $account, array $attachment, IMessage $message): void { // Gets attachment from other message $userId = $account->getMailAccount()->getUserId(); diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php index 428201244..2bb715200 100644 --- a/lib/Settings/AdminSettings.php +++ b/lib/Settings/AdminSettings.php @@ -26,6 +26,7 @@ declare(strict_types=1); namespace OCA\Mail\Settings; use OCA\Mail\AppInfo\Application; +use OCA\Mail\Service\AntiSpamService; use OCA\Mail\Service\Provisioning\Manager as ProvisioningManager; use OCP\AppFramework\Http\TemplateResponse; use OCP\IInitialStateService; @@ -40,10 +41,15 @@ class AdminSettings implements ISettings { /** @var ProvisioningManager */ private $provisioningManager; + /** @var AntiSpamService */ + private $antiSpamService; + public function __construct(IInitialStateService $initialStateService, - ProvisioningManager $provisioningManager) { + ProvisioningManager $provisioningManager, + AntiSpamService $antiSpamService) { $this->initialStateService = $initialStateService; $this->provisioningManager = $provisioningManager; + $this->antiSpamService = $antiSpamService; } public function getForm() { @@ -53,6 +59,16 @@ class AdminSettings implements ISettings { $this->provisioningManager->getConfigs() ); + $this->initialStateService->provideInitialState( + Application::APP_ID, + 'antispam_setting', + [ + 'spam' => $this->antiSpamService->getSpamEmail(), + 'ham' => $this->antiSpamService->getHamEmail(), + ] + ); + + $this->initialStateService->provideLazyInitialState( Application::APP_ID, 'ldap_aliases_integration', diff --git a/src/components/settings/AdminSettings.vue b/src/components/settings/AdminSettings.vue index 3f3076b70..6657d8b57 100644 --- a/src/components/settings/AdminSettings.vue +++ b/src/components/settings/AdminSettings.vue @@ -31,7 +31,7 @@ ) }} </p> - <div class="ap-description"> + <div class="app-description"> <h3>Account provisioning</h3> <article> <p> @@ -111,6 +111,27 @@ :submit="saveSettings" :disable="deleteProvisioning" /> </p> + <div class="app-description"> + <h3>Anti Spam Service</h3> + <article> + <p> + {{ + t( + 'mail', + 'You can set up an anti spam service email address here.' + ) + }} + <br> + {{ + t( + 'mail', + 'Any email that is marked as spam will not only be flagged as such but additionally sent to the anti spam service.' + ) + }} + </p> + </article> + <AntiSpamSettings /> + </div> </SettingsSection> </template> @@ -118,6 +139,7 @@ import logger from '../../logger' import { showError, showSuccess } from '@nextcloud/dialogs' import ProvisioningSettings from './ProvisioningSettings' +import AntiSpamSettings from './AntiSpamSettings' import SettingsSection from '@nextcloud/vue/dist/Components/SettingsSection' import { disableProvisioning, @@ -129,6 +151,7 @@ import { export default { name: 'AdminSettings', components: { + AntiSpamSettings, ProvisioningSettings, SettingsSection, }, @@ -218,7 +241,7 @@ export default { } </script> <style lang="scss" scoped> - .ap-description { + .app-description { margin-bottom: 24px; } .config-button { diff --git a/src/components/settings/AntiSpamSettings.vue b/src/components/settings/AntiSpamSettings.vue new file mode 100644 index 000000000..ca323479a --- /dev/null +++ b/src/components/settings/AntiSpamSettings.vue @@ -0,0 +1,186 @@ +<!-- + - @copyright 2021 Anna Larch <anna@nextcloud.com> + - + - @author Anna Larch <anna@nextcloud.com> + - + - @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 <http://www.gnu.org/licenses/>. + --> + +<template> + <div class="section"> + <h4>{{ t('mail','Anti Spam') }}</h4> + <p> + {{ t('mail', 'Add the email address of your anti spam report service here.') }} + </p> + <p> + {{ t('mail', 'When using this setting, a report email will be sent to the SPAM report server when a user clicks "Mark as spam".') }} + {{ t('mail', 'The original message will be attached as a "message/rfc822" attachment.') }} + </p> + <div class="form-preview-row"> + <form id="antispam-form" @submit.prevent.stop="submitForm"> + <div class="settings-group"> + <div class="group-inputs"> + <label for="mail-antispam-email-spam"> {{ t('mail', '"Mark as Spam" Email Address') }}* </label> + <br> + <input + id="mail-antispam-email-spam" + v-model="email.spam" + :disabled="loading" + name="spam" + type="email"> + <br> + <label for="mail-antispam-email-ham"> {{ t('mail', '"Mark Not Junk" Email Address') }}* </label> + <br> + <input + id="mail-antispam-email-ham" + v-model="email.ham" + :disabled="loading" + name="ham" + type="email"> + <br> + <input + :disabled="loading" + :value="t('mail', 'Save')" + class="button config-button icon-upload" + type="submit"> + <input + :disabled="loading" + :value="t('mail', 'Reset')" + class="button config-button icon-delete" + type="button" + @click="resetForm()"> + </div> + </div> + </form> + </div> + </div> +</template> +<script> +import logger from '../../logger' +import { loadState } from '@nextcloud/initial-state' +import { setAntiSpamEmail, deleteAntiSpamEmail } from '../../service/SettingsService' +import { showError, showSuccess } from '@nextcloud/dialogs' + +const email = loadState('mail', 'antispam_setting', '[]') + +export default { + name: 'AntiSpamSettings', + data() { + return { + email, + loading: false, + } + }, + methods: { + async submitForm() { + this.loading = true + + try { + await setAntiSpamEmail(email) + logger.info('anti spam email updated') + showSuccess(t('mail', 'Successfully set up anti spam email addresses')) + } catch (error) { + logger.error('Could not save email setting', { error }) + showError(t('mail', 'Error saving anti spam email addresses')) + } finally { + this.loading = false + } + }, + async resetForm() { + this.loading = true + try { + await deleteAntiSpamEmail() + logger.info('anti spam email deleted') + showSuccess(t('mail', 'Successfully deleted anti spam reporting email')) + } catch (error) { + logger.error('Could not delete email setting', { error }) + showError(t('mail', 'Error deleting anti spam reporting email')) + + } finally { + this.loading = false + this.email = [] + } + }, + }, +} +</script> + +<style lang="scss" scoped> +.form-preview-row { + display: flex; + + div:last-child { + margin-top: 10px; + } +} + +.settings-group { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + + .group-title { + min-width: 100px; + text-align: right; + margin: 10px; + font-weight: bold; + } + .group-inputs { + margin: 10px; + flex-grow: 1; + + input[type='text'] { + min-width: 200px; + } + .config-button { + line-height: 24px; + padding-left: 48px; + padding-top: 6px; + padding-bottom: 6px; + background-position: 24px; + } + } +} + +h4 { + font-weight: bold; +} + +.previews { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: 0 -10px; + + .preview-item { + flex-grow: 1; + margin: 10px; + padding: 25px; + } +} +input[type='radio'] { + display: none; +} + +.flex-row { + display: flex; +} +form { + label { + color: var(--color-text-maxcontrast); + } +} +</style> diff --git a/src/service/SettingsService.js b/src/service/SettingsService.js index 73cfd3f42..7a6509dcb 100644 --- a/src/service/SettingsService.js +++ b/src/service/SettingsService.js @@ -58,3 +58,13 @@ export const disableProvisioning = (id) => { }) return axios.delete(url).then((resp) => resp.data) } + +export const setAntiSpamEmail = (email) => { + return axios.post(generateUrl('/apps/mail/api/settings/antispam'), { spam: email.spam, ham: email.ham }) + .then((resp) => resp.data) +} + +export const deleteAntiSpamEmail = () => { + return axios.delete(generateUrl('/apps/mail/api/settings/antispam')) + .then((resp) => resp.data) +} diff --git a/tests/Integration/Framework/ImapTestAccount.php b/tests/Integration/Framework/ImapTestAccount.php index 846f332fd..0dac89327 100644 --- a/tests/Integration/Framework/ImapTestAccount.php +++ b/tests/Integration/Framework/ImapTestAccount.php @@ -48,6 +48,7 @@ trait ImapTestAccount { $mailAccount = new MailAccount(); $mailAccount->setUserId($this->getTestAccountUserId()); + $mailAccount->setName('Tester'); $mailAccount->setEmail('user@domain.tld'); $mailAccount->setInboundHost('127.0.0.1'); $mailAccount->setInboundPort(993); diff --git a/tests/Integration/Service/AntiSpamServiceIntegrationTest.php b/tests/Integration/Service/AntiSpamServiceIntegrationTest.php new file mode 100644 index 000000000..0f5d14a93 --- /dev/null +++ b/tests/Integration/Service/AntiSpamServiceIntegrationTest.php @@ -0,0 +1,109 @@ +<?php + +/** + * @copyright 2021 Anna Larch <anna@nextcloud.com> + * + * @copyright 2021 Anna Larch <anna@nextcloud.com> + * + * @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 <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Mail\Tests\Integration\Service; + +use Horde_Imap_Client; +use OC; +use OCA\Mail\Account; +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Contracts\IMailManager; +use OCA\Mail\Db\MessageMapper; +use OCA\Mail\IMAP\MessageMapper as ImapMessageMapper; +use OCA\Mail\Service\AntiSpamService; +use OCA\Mail\Service\Sync\SyncService; +use OCA\Mail\Tests\Integration\Framework\ImapTest; +use OCA\Mail\Tests\Integration\Framework\ImapTestAccount; + +class AntiSpamServiceIntegrationTest extends TestCase { + use ImapTest, + ImapTestAccount; + + /** @var AntiSpamService */ + private $service; + + public function setUp():void { + parent::setUp(); + $this->service = OC::$server->get(AntiSpamService::class); + $this->service->setSpamEmail('spam@domain.tld'); + $this->service->setHamEmail('notspam@domain.tld'); + } + + public function tearDown(): void { + $this->resetImapAccount(); + $this->service->deleteConfig(); + } + + public function testFlagJunkWithSpamReportActive(): void { + // First, set up account and retrieve sync token + $this->resetImapAccount(); + $account = $this->createTestAccount(); + + /** @var SyncService $syncService */ + $syncService = OC::$server->get(SyncService::class); + /** @var ImapMessageMapper $imapMessageMapper */ + $imapMessageMapper = OC::$server->get(ImapMessageMapper::class); + /** @var MessageMapper $messageMapper */ + $messageMapper = OC::$server->get(MessageMapper::class); + /** @var IMailManager $mailManager */ + $mailManager = OC::$server->get(IMailManager::class); + $mailBoxes = $mailManager->getMailboxes(new Account($account)); + $inbox = null; + foreach ($mailBoxes as $mailBox) { + if ($mailBox->getName() === 'INBOX') { + $inbox = $mailBox; + break; + } + } + + // Second, put a new message into the mailbox + $message = $this->getMessageBuilder() + ->from('buffington@domain.tld') + ->to('user@domain.tld') + ->finish(); + $newUid = $this->saveMessage($inbox->getName(), $message, $account); + + // sync in between creating and flagging otherwise it can't be found + $syncService->syncMailbox( + new Account($account), + $inbox, + Horde_Imap_Client::SYNC_NEWMSGSUIDS | Horde_Imap_Client::SYNC_FLAGSUIDS | Horde_Imap_Client::SYNC_VANISHEDUIDS, + null, + false + ); + + // now we flag this message as junk + $mailManager->flagMessage(new Account($account), $inbox->getName(), $newUid, 'junk', true); + + // if everything runs through, we can assert the run has been fine, + // but we can't really test if Listener and Transmission have actually sent the message + $this->addToAssertionCount(1); + + // now we flag this message as not junk + $mailManager->flagMessage(new Account($account), $inbox->getName(), $newUid, 'notjunk', true); + + // same as before + $this->addToAssertionCount(1); + } +} diff --git a/tests/Unit/Listener/HamReportListenerTest.php b/tests/Unit/Listener/HamReportListenerTest.php new file mode 100644 index 000000000..71c70f4c6 --- /dev/null +++ b/tests/Unit/Listener/HamReportListenerTest.php @@ -0,0 +1,156 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Anna Larch <anna@nextcloud.com> + * + * @author Anna Larch <anna@nextcloud.com> + * + * @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 <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Tests\Unit\Listener; + +use ChristophWurst\Nextcloud\Testing\ServiceMockObject; +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Account; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\Tag; +use OCA\Mail\Events\MessageFlaggedEvent; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Listener\HamReportListener; +use OCA\Mail\Listener\SpamReportListener; +use OCP\EventDispatcher\Event; + +class HamReportListenerTest extends TestCase { + + /** @var ServiceMockObject */ + private $serviceMock; + + /** @var SpamReportListener */ + private $listener; + + protected function setUp(): void { + parent::setUp(); + + $this->serviceMock = $this->createServiceMock(HamReportListener::class); + $this->listener = $this->serviceMock->getService(); + } + + public function testHandleUnrelated(): void { + $event = new Event(); + + $this->serviceMock->getParameter('antiSpamService') + ->expects(self::never()) + ->method('sendReportEmail'); + $this->serviceMock->getParameter('logger') + ->expects(self::never()) + ->method('error'); + + $this->listener->handle($event); + } + + public function testHandleNotFlaggedJunk(): void { + $account = $this->createMock(Account::class); + $mailbox = $this->createMock(Mailbox::class); + $event = new MessageFlaggedEvent( + $account, + $mailbox, + 123, + Tag::LABEL_IMPORTANT, + true + ); + + $this->serviceMock->getParameter('antiSpamService') + ->expects(self::never()) + ->method('sendReportEmail'); + $this->serviceMock->getParameter('logger') + ->expects(self::never()) + ->method('error'); + + $this->listener->handle($event); + } + + public function testHandleNotUnflaggingNonjunk(): void { + $account = $this->createMock(Account::class); + $mailbox = $this->createMock(Mailbox::class); + $event = new MessageFlaggedEvent( + $account, + $mailbox, + 123, + '$notjunk', + false + ); + + $this->serviceMock->getParameter('antiSpamService') + ->expects(self::never()) + ->method('getHamEmail'); + $this->serviceMock->getParameter('antiSpamService') + ->expects(self::never()) + ->method('sendReportEmail'); + $this->serviceMock->getParameter('logger') + ->expects(self::never()) + ->method('error'); + + $this->listener->handle($event); + } + + public function testHandleJunkExceptionOnSend() : void { + $account = $this->createMock(Account::class); + $mailbox = $this->createMock(Mailbox::class); + $event = new MessageFlaggedEvent( + $account, + $mailbox, + 123, + '$notjunk', + true + ); + + $this->serviceMock->getParameter('antiSpamService') + ->expects(self::once()) + ->method('sendReportEmail') + ->with($account, $mailbox, 123, '$notjunk') + ->willThrowException(new ServiceException()); + $this->serviceMock->getParameter('logger') + ->expects(self::once()) + ->method('error'); + + $this->listener->handle($event); + } + + public function testHandle(): void { + $account = $this->createMock(Account::class); + $mailbox = $this->createMock(Mailbox::class); + $event = new MessageFlaggedEvent( + $account, + $mailbox, + 123, + '$notjunk', + true + ); + + $this->serviceMock->getParameter('antiSpamService') + ->expects(self::once()) + ->method('sendReportEmail') + ->with($account, $mailbox, 123, '$notjunk'); + $this->serviceMock->getParameter('logger') + ->expects(self::never()) + ->method('error'); + + $this->listener->handle($event); + } +} diff --git a/tests/Unit/Listener/SpamReportListenerTest.php b/tests/Unit/Listener/SpamReportListenerTest.php new file mode 100644 index 000000000..d7068990b --- /dev/null +++ b/tests/Unit/Listener/SpamReportListenerTest.php @@ -0,0 +1,149 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Anna Larch <anna@nextcloud.com> + * + * @author Anna Larch <anna@nextcloud.com> + * + * @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 <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Tests\Unit\Listener; + +use ChristophWurst\Nextcloud\Testing\ServiceMockObject; +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Account; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Db\Tag; +use OCA\Mail\Events\MessageFlaggedEvent; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Listener\SpamReportListener; +use OCP\EventDispatcher\Event; + +class SpamReportListenerTest extends TestCase { + + /** @var ServiceMockObject */ + private $serviceMock; + + /** @var SpamReportListener */ + private $listener; + + protected function setUp(): void { + parent::setUp(); + + $this->serviceMock = $this->createServiceMock(SpamReportListener::class); + $this->listener = $this->serviceMock->getService(); + } + + public function testHandleUnrelated(): void { + $event = new Event(); + + $this->serviceMock->getParameter('antiSpamService') + ->expects(self::never()) + ->method('sendReportEmail'); + $this->serviceMock->getParameter('logger') + ->expects(self::never()) + ->method('error'); + + $this->listener->handle($event); + } + + public function testHandleNotFlaggedJunk(): void { + $account = $this->createMock(Account::class); + $mailbox = $this->createMock(Mailbox::class); + $event = new MessageFlaggedEvent( + $account, + $mailbox, + 123, + Tag::LABEL_IMPORTANT, + true + ); + + $this->serviceMock->getParameter('antiSpamService') + ->expects(self::never()) + ->method('sendReportEmail'); + $this->serviceMock->getParameter('logger') + ->expects(self::never()) + ->method('error'); + + $this->listener->handle($event); + } + + public function testHandleNotUnflaggingJunk(): void { + $account = $this->createMock(Account::class); + $mailbox = $this->createMock(Mailbox::class); + $event = new MessageFlaggedEvent( + $account, + $mailbox, + 123, + '$junk', + false + ); + + $this->serviceMock->getParameter('logger') + ->expects(self::never()) + ->method('error'); + + $this->listener->handle($event); + } + + public function testHandleJunkExceptionOnSend(): void { + $account = $this->createMock(Account::class); + $mailbox = $this->createMock(Mailbox::class); + $event = new MessageFlaggedEvent( + $account, + $mailbox, + 123, + '$junk', + true + ); + + $this->serviceMock->getParameter('antiSpamService') + ->expects(self::once()) + ->method('sendReportEmail') + ->with($account, $mailbox, 123, '$junk') + ->willThrowException(new ServiceException()); + $this->serviceMock->getParameter('logger') + ->expects(self::once()) + ->method('error'); + + $this->listener->handle($event); + } + + public function testHandle(): void { + $account = $this->createMock(Account::class); + $mailbox = $this->createMock(Mailbox::class); + $event = new MessageFlaggedEvent( + $account, + $mailbox, + 123, + '$junk', + true + ); + + $this->serviceMock->getParameter('antiSpamService') + ->expects(self::once()) + ->method('sendReportEmail') + ->with($account, $mailbox, 123, '$junk'); + $this->serviceMock->getParameter('logger') + ->expects(self::never()) + ->method('error'); + + $this->listener->handle($event); + } +} diff --git a/tests/Unit/Service/AntiSpamServiceTest.php b/tests/Unit/Service/AntiSpamServiceTest.php new file mode 100644 index 000000000..d8da88b42 --- /dev/null +++ b/tests/Unit/Service/AntiSpamServiceTest.php @@ -0,0 +1,204 @@ +<?php + +/** + * @copyright 2021 Anna Larch <anna@nextcloud.com> + * + * @author Anna Larch <anna@nextcloud.com> + * + * 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 <http://www.gnu.org/licenses/> + * + */ + +namespace OCA\Mail\Tests\Unit\Service; + +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Account; +use OCA\Mail\Contracts\IMailTransmission; +use OCA\Mail\Db\MessageMapper; +use OCA\Mail\Events\MessageFlaggedEvent; +use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Db\Mailbox; +use OCA\Mail\Model\NewMessageData; +use OCA\Mail\Service\AntiSpamService; +use OCP\IConfig; +use PHPUnit\Framework\MockObject\MockObject; + +class AntiSpamServiceTest extends TestCase { + + /** @var AntiSpamService */ + private $service; + + /** @var IConfig|MockObject */ + private $config; + + /** @var MessageMapper|MockObject */ + private $messageMapper; + + /** @var IMailTransmission|MockObject */ + private $transmission; + + protected function setUp(): void { + parent::setUp(); + + $this->config = $this->createMock(IConfig::class); + $this->messageMapper = $this->createMock(MessageMapper::class); + $this->transmission = $this->createMock(IMailTransmission::class); + + $this->service = new AntiSpamService( + $this->config, + $this->messageMapper, + $this->transmission + ); + } + + public function testSendReportEmailNoEmailFound(): void { + $event = $this->createConfiguredMock(MessageFlaggedEvent::class, [ + 'getAccount' => $this->createMock(Account::class), + 'getMailbox' => $this->createMock(Mailbox::class), + 'getFlag' => '$junk' + ]); + + $this->config->expects(self::once()) + ->method('getAppValue') + ->with('mail', 'antispam_reporting_spam') + ->willReturn(''); + $this->messageMapper->expects(self::never()) + ->method('getIdForUid'); + $this->transmission->expects(self::never()) + ->method('sendMessage'); + + $this->service->sendReportEmail($event->getAccount(), $event->getMailbox(), 123, $event->getFlag()); + } + + public function testSendReportEmailNoMessageFound(): void { + $event = $this->createConfiguredMock(MessageFlaggedEvent::class, [ + 'getAccount' => $this->createMock(Account::class), + 'getMailbox' => $this->createMock(Mailbox::class), + 'getFlag' => '$junk' + ]); + + $this->config->expects(self::once()) + ->method('getAppValue') + ->with('mail', 'antispam_reporting_spam') + ->willReturn('test@test.com'); + $this->messageMapper->expects(self::once()) + ->method('getIdForUid') + ->with($event->getMailbox(), 123) + ->willReturn(null); + $this->expectException(ServiceException::class); + $this->transmission->expects(self::never()) + ->method('sendMessage'); + + $this->service->sendReportEmail($event->getAccount(), $event->getMailbox(), 123, $event->getFlag()); + } + + public function testSendReportEmailTransmissionError(): void { + $event = $this->createConfiguredMock(MessageFlaggedEvent::class, [ + 'getAccount' => $this->createMock(Account::class), + 'getMailbox' => $this->createMock(Mailbox::class), + 'getFlag' => '$junk' + ]); + + $this->config->expects(self::once()) + ->method('getAppValue') + ->with('mail', 'antispam_reporting_spam') + ->willReturn('test@test.com'); + $messageData = NewMessageData::fromRequest( + $event->getAccount(), + 'test@test.com', + null, + null, + 'Learn as Junk', + 'Learn as Junk', + [['id' => 123, 'type' => 'message/rfc822']] + ); + + $this->messageMapper->expects(self::once()) + ->method('getIdForUid') + ->with($event->getMailbox(), 123) + ->willReturn(123); + $this->transmission->expects(self::once()) + ->method('sendMessage') + ->with($messageData) + ->willThrowException(new ServiceException()); + $this->expectException(ServiceException::class); + + $this->service->sendReportEmail($event->getAccount(), $event->getMailbox(), 123, $event->getFlag()); + } + + public function testSendReportEmail(): void { + $event = $this->createConfiguredMock(MessageFlaggedEvent::class, [ + 'getAccount' => $this->createMock(Account::class), + 'getMailbox' => $this->createMock(Mailbox::class), + 'getFlag' => '$junk' + ]); + + $this->config->expects(self::once()) + ->method('getAppValue') + ->with('mail', 'antispam_reporting_spam') + ->willReturn('test@test.com'); + $messageData = NewMessageData::fromRequest( + $event->getAccount(), + 'test@test.com', + null, + null, + 'Learn as Junk', + 'Learn as Junk', + [['id' => 123, 'type' => 'message/rfc822']] + ); + + $this->messageMapper->expects(self::once()) + ->method('getIdForUid') + ->with($event->getMailbox(), 123) + ->willReturn(123); + $this->transmission->expects(self::once()) + ->method('sendMessage') + ->with($messageData); + + $this->service->sendReportEmail($event->getAccount(), $event->getMailbox(), 123, $event->getFlag()); + } + + public function testSendReportEmailForHam(): void { + $event = $this->createConfiguredMock(MessageFlaggedEvent::class, [ + 'getAccount' => $this->createMock(Account::class), + 'getMailbox' => $this->createMock(Mailbox::class), + 'getFlag' => '$notjunk' + ]); + + $this->config->expects(self::once()) + ->method('getAppValue') + ->with('mail', 'antispam_reporting_ham') + ->willReturn('test@test.com'); + $messageData = NewMessageData::fromRequest( + $event->getAccount(), + 'test@test.com', + null, + null, + 'Learn as Not Junk', + 'Learn as Not Junk', + [['id' => 123, 'type' => 'message/rfc822']] + ); + + $this->messageMapper->expects(self::once()) + ->method('getIdForUid') + ->with($event->getMailbox(), 123) + ->willReturn(123); + $this->transmission->expects(self::once()) + ->method('sendMessage') + ->with($messageData); + + $this->service->sendReportEmail($event->getAccount(), $event->getMailbox(), 123, $event->getFlag()); + } +} diff --git a/tests/Unit/Settings/AdminSettingsTest.php b/tests/Unit/Settings/AdminSettingsTest.php index 7cdfca83a..a2eea685d 100644 --- a/tests/Unit/Settings/AdminSettingsTest.php +++ b/tests/Unit/Settings/AdminSettingsTest.php @@ -54,12 +54,19 @@ class AdminSettingsTest extends TestCase { } public function testGetForm() { - $this->serviceMock->getParameter('initialStateService')->expects($this->once()) + $this->serviceMock->getParameter('initialStateService')->expects($this->exactly(2)) ->method('provideInitialState') - ->with( - Application::APP_ID, - 'provisioning_settings', - $this->anything() + ->withConsecutive( + [ + Application::APP_ID, + 'provisioning_settings', + $this->anything() + ], + [ + Application::APP_ID, + 'antispam_setting', + $this->anything() + ] ); $expected = new TemplateResponse(Application::APP_ID, 'settings-admin'); diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index a8707bdf4..19d2f3e35 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -1,11 +1,13 @@ <?xml version="1.0" encoding="UTF-8"?> <files psalm-version="4.x-dev@"> <file src="lib/AppInfo/Application.php"> - <MissingDependency occurrences="12"> + <MissingDependency occurrences="14"> <code>DraftSavedEvent</code> <code>MailboxesSynchronizedEvent</code> <code>MessageDeletedEvent</code> <code>MessageFlaggedEvent</code> + <code>MessageFlaggedEvent</code> + <code>MessageFlaggedEvent</code> <code>MessageSentEvent</code> <code>MessageSentEvent</code> <code>MessageSentEvent</code> @@ -228,6 +230,12 @@ <code>MessageSentEvent</code> </MissingDependency> </file> + <file src="lib/Listener/HamReportListener.php"> + <MissingDependency occurrences="2"> + <code>HamReportListener</code> + <code>MessageFlaggedEvent</code> + </MissingDependency> + </file> <file src="lib/Listener/InteractionListener.php"> <MissingDependency occurrences="2"> <code>InteractionListener</code> @@ -258,12 +266,23 @@ <code>SaveSentMessageListener</code> </MissingDependency> </file> + <file src="lib/Listener/SpamReportListener.php"> + <MissingDependency occurrences="2"> + <code>MessageFlaggedEvent</code> + <code>SpamReportListener</code> + </MissingDependency> + </file> <file src="lib/Listener/UserDeletedListener.php"> <MissingDependency occurrences="2"> <code>UserDeletedEvent</code> <code>UserDeletedListener</code> </MissingDependency> </file> + <file src="lib/Service/AntiSpamService.php"> + <MissingDependency occurrences="1"> + <code>MessageFlaggedEvent</code> + </MissingDependency> + </file> <file src="lib/Service/Classification/ImportanceClassifier.php"> <InvalidScalarArgument occurrences="1"> <code>$predictedValidationLabel</code> |