Merge pull request #8610 from nextcloud/enh/quota-depletion-warning

feat: add notification for quota depletion
This commit is contained in:
Christoph Wurst 2023-07-28 14:55:14 +02:00 коммит произвёл GitHub
Родитель 6c147d61d8 8041d81153
Коммит a15ccbe30e
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 600 добавлений и 1 удалений

Просмотреть файл

@ -22,7 +22,7 @@ Positive:
Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud.com/blog/nextcloud-ethical-ai-rating/).
]]></description>
<version>3.3.0-alpha.1</version>
<version>3.3.0-alpha.2</version>
<licence>agpl</licence>
<author>Greta Doçi</author>
<author homepage="https://github.com/nextcloud/groupware">Nextcloud Groupware Team</author>

Просмотреть файл

@ -46,6 +46,7 @@ use OCA\Mail\Db\MailAccount;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\Model\IMessage;
use OCA\Mail\Model\Message;
use OCA\Mail\Service\Quota;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\Security\ICrypto;
@ -229,4 +230,18 @@ class Account implements JsonSerializable {
public function newMessage() {
return new Message();
}
/**
* Set the quota percentage
* @param Quota $quota
* @return void
*/
public function calculateAndSetQuotaPercentage(Quota $quota): void {
$percentage = (int)round($quota->getUsage() / $quota->getLimit() * 100);
$this->account->setQuotaPercentage($percentage);
}
public function getQuotaPercentage(): ?int {
return $this->account->getQuotaPercentage();
}
}

Просмотреть файл

@ -64,6 +64,7 @@ use OCA\Mail\Listener\OauthTokenRefreshListener;
use OCA\Mail\Listener\SaveSentMessageListener;
use OCA\Mail\Listener\SpamReportListener;
use OCA\Mail\Listener\UserDeletedListener;
use OCA\Mail\Notification\Notifier;
use OCA\Mail\Search\Provider;
use OCA\Mail\Service\Attachment\AttachmentService;
use OCA\Mail\Service\AvatarService;
@ -137,6 +138,8 @@ class Application extends App implements IBootstrap {
$context->registerDashboardWidget(UnreadMailWidget::class);
$context->registerSearchProvider(Provider::class);
$context->registerNotifierService(Notifier::class);
// bypass Horde Translation system
Horde_Translation::setHandler('Horde_Imap_Client', new HordeTranslationHandler());
Horde_Translation::setHandler('Horde_Mime', new HordeTranslationHandler());

Просмотреть файл

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Anna Larch <anna.larch@gmx.net>
*
* @author Anna Larch <anna.larch@gmx.net>
*
* @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\BackgroundJob;
use OCA\Mail\Account;
use OCA\Mail\Contracts\IMailManager;
use OCA\Mail\Service\AccountService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJobList;
use OCP\BackgroundJob\TimedJob;
use OCP\IUserManager;
use OCP\Notification\IManager;
use Psr\Log\LoggerInterface;
use function sprintf;
class QuotaJob extends TimedJob {
private IUserManager $userManager;
private AccountService $accountService;
private IMailManager $mailManager;
private LoggerInterface $logger;
private IJobList $jobList;
private IManager $notificationManager;
public function __construct(ITimeFactory $time,
IUserManager $userManager,
AccountService $accountService,
IMailManager $mailManager,
IManager $notificationManager,
LoggerInterface $logger,
IJobList $jobList) {
parent::__construct($time);
$this->userManager = $userManager;
$this->accountService = $accountService;
$this->logger = $logger;
$this->jobList = $jobList;
$this->mailManager = $mailManager;
$this->setInterval(24 * 60 * 60);
$this->setTimeSensitivity(self::TIME_SENSITIVE);
$this->notificationManager = $notificationManager;
}
/**
* @return void
*/
protected function run($argument): void {
$accountId = (int)$argument['accountId'];
try {
/** @var Account $account */
$account = $this->accountService->findById($accountId);
} catch (DoesNotExistException $e) {
$this->logger->debug('Could not find account <' . $accountId . '> removing from jobs');
$this->jobList->remove(self::class, $argument);
return;
}
$user = $this->userManager->get($account->getUserId());
if ($user === null || !$user->isEnabled()) {
$this->logger->debug(sprintf(
'Account %d of user %s could not be found or was disabled, skipping quota query',
$account->getId(),
$account->getUserId()
));
return;
}
$quota = $this->mailManager->getQuota($account);
if($quota === null) {
$this->logger->debug('Could not get quota information for account <' . $account->getEmail() . '>', ['app' => 'mail']);
return;
}
$previous = $account->getMailAccount()->getQuotaPercentage();
$account->calculateAndSetQuotaPercentage($quota);
$this->accountService->update($account->getMailAccount());
$current = $account->getQuotaPercentage();
// Only notify if we've reached the rising edge
if ($previous < $current && $previous <= 90 && $current > 90) {
$this->logger->debug('New quota information for <' . $account->getEmail() . '> - previous: ' . $previous . ', current: ' . $current);
$time = $this->time->getDateTime('now');
$notification = $this->notificationManager->createNotification();
$notification
->setApp('mail')
->setUser($account->getUserId())
->setObject('quota', (string)$accountId)
->setSubject('quota_depleted', [
'id' => $accountId,
'account_email' => $account->getEmail()
])
->setDateTime($time)
->setMessage('percentage', [
'id' => $accountId,
'quota_percentage' => $current,
]
);
$this->notificationManager->notify($notification);
}
}
}

Просмотреть файл

@ -106,6 +106,8 @@ use OCP\AppFramework\Db\Entity;
* @method void setOauthTokenTtl(int $ttl)
* @method int|null getSmimeCertificateId()
* @method void setSmimeCertificateId(int|null $smimeCertificateId)
* @method int|null getQuotaPercentage()
* @method void setQuotaPercentage(int $quota);
*/
class MailAccount extends Entity {
public const SIGNATURE_MODE_PLAIN = 0;
@ -171,6 +173,9 @@ class MailAccount extends Entity {
/** @var int|null */
protected $smimeCertificateId;
/** @var int|null */
protected $quotaPercentage;
/**
* @param array $params
*/
@ -239,6 +244,7 @@ class MailAccount extends Entity {
$this->addType('signatureAboveQuote', 'boolean');
$this->addType('signatureMode', 'int');
$this->addType('smimeCertificateId', 'integer');
$this->addType('quotaPercentage', 'integer');
}
/**
@ -268,6 +274,7 @@ class MailAccount extends Entity {
'signatureAboveQuote' => ($this->isSignatureAboveQuote() === true),
'signatureMode' => $this->getSignatureMode(),
'smimeCertificateId' => $this->getSmimeCertificateId(),
'quotaPercentage' => $this->getQuotaPercentage(),
];
if (!is_null($this->getOutboundHost())) {

Просмотреть файл

@ -26,6 +26,7 @@ declare(strict_types=1);
namespace OCA\Mail\Migration;
use OCA\Mail\BackgroundJob\PreviewEnhancementProcessingJob;
use OCA\Mail\BackgroundJob\QuotaJob;
use OCA\Mail\BackgroundJob\SyncJob;
use OCA\Mail\BackgroundJob\TrainImportanceClassifierJob;
use OCA\Mail\Db\MailAccount;
@ -61,6 +62,7 @@ class FixBackgroundJobs implements IRepairStep {
$this->jobList->add(SyncJob::class, ['accountId' => $account->getId()]);
$this->jobList->add(TrainImportanceClassifierJob::class, ['accountId' => $account->getId()]);
$this->jobList->add(PreviewEnhancementProcessingJob::class, ['accountId' => $account->getId()]);
$this->jobList->add(QuotaJob::class, ['accountId' => $account->getId()]);
$output->advance();
}
$output->finishProgress();

Просмотреть файл

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Anna Larch <anna.larch@gmx.net>
*
* @author Anna Larch <anna.larch@gmx.net>
*
* @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\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version3300Date20230706140531 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$accountsTable = $schema->getTable('mail_accounts');
if (!$accountsTable->hasColumn('quota_percentage')) {
$accountsTable->addColumn('quota_percentage', Types::INTEGER, [
'notnull' => false
]);
}
return $schema;
}
}

Просмотреть файл

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* Calendar App
*
* @copyright 2023 Anna Larch <anna.larch@gmx.net>
*
* @author Anna Larch <anna.larch@gmx.net>
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
* License as published by the Free Software Foundation; either
* version 3 of the License, or any later version.
*
* This library 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 library. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Mail\Notification;
use OCA\Mail\AppInfo\Application;
use OCP\IURLGenerator;
use OCP\L10N\IFactory;
use OCP\Notification\INotification;
use OCP\Notification\INotifier;
class Notifier implements INotifier {
private IFactory $factory;
private IURLGenerator $url;
public function __construct(IFactory $factory,
IURLGenerator $url) {
$this->factory = $factory;
$this->url = $url;
}
public function getID(): string {
return Application::APP_ID;
}
/**
* Human-readable name describing the notifier
* @return string
*/
public function getName(): string {
return $this->factory->get(Application::APP_ID)->t('Mail');
}
public function prepare(INotification $notification, string $languageCode): INotification {
if ($notification->getApp() !== Application::APP_ID) {
// Not my app => throw
throw new \InvalidArgumentException();
}
// Read the language from the notification
$l = $this->factory->get(Application::APP_ID, $languageCode);
switch ($notification->getSubject()) {
// Deal with known subjects
case 'quota_depleted':
$parameters = $notification->getSubjectParameters();
$notification->setRichSubject($l->t('You are reaching your mailbox quota limit for {account}'), [
'account' => [
'type' => 'highlight',
'id' => $parameters['id'],
'name' => $parameters['account_email']
]
]);
$notification->setParsedSubject($notification->getRichSubject());
$messageParameters = $notification->getMessageParameters();
$notification->setRichMessage($l->t('You are currently using {percentage} of your mailbox storage. Please make some space by deleting unneeded emails.'),
[
'percentage' => [
'type' => 'highlight',
'id' => $messageParameters['id'],
'name' => (string)$messageParameters['quota_percentage'] . '%',
]
]);
$notification->setParsedMessage($notification->getParsedMessage());
break;
default:
throw new \InvalidArgumentException();
}
return $notification;
}
}

Просмотреть файл

@ -26,6 +26,7 @@ namespace OCA\Mail\Service;
use OCA\Mail\Account;
use OCA\Mail\BackgroundJob\PreviewEnhancementProcessingJob;
use OCA\Mail\BackgroundJob\QuotaJob;
use OCA\Mail\BackgroundJob\SyncJob;
use OCA\Mail\BackgroundJob\TrainImportanceClassifierJob;
use OCA\Mail\Db\MailAccount;
@ -150,6 +151,7 @@ class AccountService {
$this->jobList->add(SyncJob::class, ['accountId' => $newAccount->getId()]);
$this->jobList->add(TrainImportanceClassifierJob::class, ['accountId' => $newAccount->getId()]);
$this->jobList->add(PreviewEnhancementProcessingJob::class, ['accountId' => $newAccount->getId()]);
$this->jobList->add(QuotaJob::class, ['accountId' => $newAccount->getId()]);
return $newAccount;
}
@ -172,4 +174,11 @@ class AccountService {
$mailAccount->setSignature($signature);
$this->mapper->save($mailAccount);
}
/**
* @return Account[]
*/
public function getAllAcounts(): array {
return $this->mapper->getAllAccounts();
}
}

Просмотреть файл

@ -46,6 +46,7 @@ class MailAccountTest extends TestCase {
$a->setEditorMode('html');
$a->setProvisioningId(null);
$a->setOrder(13);
$a->setQuotaPercentage(10);
$this->assertEquals([
'id' => 12345,
@ -74,6 +75,7 @@ class MailAccountTest extends TestCase {
'signatureAboveQuote' => false,
'signatureMode' => null,
'smimeCertificateId' => null,
'quotaPercentage' => 10,
], $a->toJson());
}
@ -105,6 +107,7 @@ class MailAccountTest extends TestCase {
'signatureAboveQuote' => false,
'signatureMode' => null,
'smimeCertificateId' => null,
'quotaPercentage' => null,
];
$a = new MailAccount($expected);
// TODO: fix inconsistency

Просмотреть файл

@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
/*
* @copyright 2023 Anna Larch <anna.larch@gmx.net>
*
* @author 2023 Anna Larch <anna.larch@gmx.net>
*
* @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\BackgroundJob;
use ChristophWurst\Nextcloud\Testing\ServiceMockObject;
use ChristophWurst\Nextcloud\Testing\TestCase;
use OC\BackgroundJob\JobList;
use OCA\Mail\Account;
use OCA\Mail\BackgroundJob\QuotaJob;
use OCA\Mail\BackgroundJob\SyncJob;
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Service\Quota;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\ILogger;
use OCP\IUser;
use OCP\Notification\INotification;
class QuotaJobTest extends TestCase {
/** @var ServiceMockObject*/
private $serviceMock;
/** @var SyncJob */
private $job;
protected function setUp(): void {
parent::setUp();
$this->serviceMock = $this->createServiceMock(QuotaJob::class);
$this->job = $this->serviceMock->getService();
// Make sure the job is actually run
$this->serviceMock->getParameter('time')
->method('getTime')
->willReturn(500000);
// Set our common argument
$this->job->setArgument([
'accountId' => 123,
]);
// Set a fake ID
$this->job->setId(99);
}
public function testAccountDoesntExist(): void {
$this->serviceMock->getParameter('accountService')
->expects(self::once())
->method('findById')
->with(123)
->willThrowException(new DoesNotExistException(''));
$this->serviceMock->getParameter('logger')
->expects(self::once())
->method('debug')
->with('Could not find account <123> removing from jobs');
$this->serviceMock->getParameter('jobList')
->expects(self::once())
->method('remove')
->with(QuotaJob::class, ['accountId' => 123]);
$this->serviceMock->getParameter('mailManager')
->expects(self::never())
->method('getQuota');
$this->job->setArgument([
'accountId' => 123,
]);
$this->job->setLastRun(0);
$this->job->execute(
$this->createMock(JobList::class),
$this->createMock(ILogger::class)
);
}
public function testUserDoesntExist(): void {
$account = $this->createMock(Account::class);
$account->method('getId')->willReturn(123);
$account->method('getUserId')->willReturn('user123');
$this->serviceMock->getParameter('accountService')
->expects(self::once())
->method('findById')
->with(123)
->willReturn($account);
$user = $this->createMock(IUser::class);
$this->serviceMock->getParameter('userManager')
->expects(self::once())
->method('get')
->with('user123')
->willReturn($user);
$this->serviceMock->getParameter('logger')
->expects(self::once())
->method('debug')
->with('Account 123 of user user123 could not be found or was disabled, skipping quota query');
$this->serviceMock->getParameter('mailManager')
->expects(self::never())
->method('getQuota');
$this->job->setArgument([
'accountId' => 123,
]);
$this->job->execute(
$this->createMock(JobList::class),
$this->createMock(ILogger::class)
);
}
public function testQuotaTooLow(): void {
$oldQuota = 10;
$newQuota = 20;
$quotaDTO = new Quota(20, 100);
$mailAccount = $this->createMock(MailAccount::class);
$account = $this->createConfiguredMock(Account::class, [
'getId' => 123,
'getUserId' => 'user123',
'getMailAccount' => $mailAccount,
]);
$user = $this->createConfiguredMock(IUser::class, [
'isEnabled' => true,
]);
$this->serviceMock->getParameter('accountService')
->expects(self::once())
->method('findById')
->with(123)
->willReturn($account);
$this->serviceMock->getParameter('userManager')
->expects(self::once())
->method('get')
->with('user123')
->willReturn($user);
$this->serviceMock->getParameter('logger')
->expects(self::never())
->method('debug');
$this->serviceMock->getParameter('mailManager')
->expects(self::once())
->method('getQuota')
->willReturn($quotaDTO);
$account->expects(self::once())
->method('calculateAndSetQuotaPercentage')
->with($quotaDTO);
$account->expects(self::exactly(2))
->method('getMailAccount')
->willReturn($mailAccount);
$account->expects(self::once())
->method('getQuotaPercentage')
->willReturn($newQuota);
$this->serviceMock->getParameter('accountService')
->expects(self::once())
->method('update')
->with($mailAccount);
$this->job->setArgument([
'accountId' => 123,
]);
$this->job->execute(
$this->createMock(JobList::class),
$this->createMock(ILogger::class)
);
}
public function testQuotaWithNotification(): void {
$oldQuota = 85;
$newQuota = 95;
$quotaDTO = new Quota(95, 100);
$mailAccount = $this->createMock(MailAccount::class);
$account = $this->createConfiguredMock(Account::class, [
'getId' => 123,
'getUserId' => 'user123',
'getMailAccount' => $mailAccount,
'getEmail' => 'user123@test.com',
]);
$user = $this->createConfiguredMock(IUser::class, [
'isEnabled' => true,
]);
$notification = $this->createMock(INotification::class);
$this->serviceMock->getParameter('accountService')
->expects(self::once())
->method('findById')
->with(123)
->willReturn($account);
$this->serviceMock->getParameter('userManager')
->expects(self::once())
->method('get')
->with('user123')
->willReturn($user);
$this->serviceMock->getParameter('mailManager')
->expects(self::once())
->method('getQuota')
->willReturn($quotaDTO);
$account->expects(self::once())
->method('calculateAndSetQuotaPercentage')
->with($quotaDTO);
$account->expects(self::exactly(2))
->method('getMailAccount')
->willReturn($mailAccount);
$account->expects(self::once())
->method('getQuotaPercentage')
->willReturn($newQuota);
$account->expects(self::exactly(2))
->method('getUserId')
->willReturn('user123');
$account->expects(self::exactly(2))
->method('getEmail')
->willReturn('user123@test.com');
$this->serviceMock->getParameter('accountService')
->expects(self::once())
->method('update')
->with($mailAccount);
$this->serviceMock->getParameter('logger')
->expects(self::once())
->method('debug');
$time = new \DateTime('now');
$this->serviceMock->getParameter('time')
->expects(self::once())
->method('getDateTime')
->willReturn($time);
$this->serviceMock->getParameter('notificationManager')
->expects(self::once())
->method('createNotification')
->willReturn($notification);
$notification->expects(self::once())
->method('setApp')
->with('mail')
->willReturn($notification);
$notification->expects(self::once())
->method('setUser')
->with('user123')
->willReturn($notification);
$notification->expects(self::once())
->method('setObject')
->with('quota', 123)
->willReturn($notification);
$notification->expects(self::once())
->method('setSubject')
->with('quota_depleted', [
'id' => 123,
'account_email' => 'user123@test.com'
])
->willReturn($notification);
$notification->expects(self::once())
->method('setDateTime')
->with($time)
->willReturn($notification);
$notification->expects(self::once())
->method('setMessage')
->with('percentage', [
'id' => 123,
'quota_percentage' => $newQuota,
])
->willReturn($notification);
$this->serviceMock->getParameter('notificationManager')
->expects(self::once())
->method('notify')
->with($notification);
$this->job->setArgument([
'accountId' => 123,
]);
$this->job->execute(
$this->createMock(JobList::class),
$this->createMock(ILogger::class)
);
}
}