diff options
author | Julien Barnoin <julien@berryandcloud.com> | 2021-08-18 20:57:09 +0300 |
---|---|---|
committer | Joas Schilling <coding@schilljs.com> | 2021-10-11 17:03:31 +0300 |
commit | 3dcfc90c2efac3cb95d7dde823015c086f13377f (patch) | |
tree | d504ca12ceeb5919b0488f913c3eb77c3271ea4b /lib | |
parent | 02f671db0f76024674713d3a141e1f0673fba6fb (diff) |
Sending unseen notifications as email periodically. Includes a new user settings page for notifications to configure this option.
Signed-off-by: Julien Barnoin <julien@berryandcloud.com>
Diffstat (limited to 'lib')
-rw-r--r-- | lib/BackgroundJob/SendNotificationMails.php | 50 | ||||
-rw-r--r-- | lib/Controller/SettingsController.php | 90 | ||||
-rw-r--r-- | lib/Handler.php | 32 | ||||
-rw-r--r-- | lib/MailNotifications.php | 330 | ||||
-rw-r--r-- | lib/Settings/Personal.php | 112 | ||||
-rw-r--r-- | lib/Settings/PersonalSection.php | 87 |
6 files changed, 701 insertions, 0 deletions
diff --git a/lib/BackgroundJob/SendNotificationMails.php b/lib/BackgroundJob/SendNotificationMails.php new file mode 100644 index 0000000..b7e5e86 --- /dev/null +++ b/lib/BackgroundJob/SendNotificationMails.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); +/** + * @copyright Copyright (c) 2021 Julien Barnoin <julien@barnoin.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\Notifications\BackgroundJob; + +use OCP\BackgroundJob\TimedJob; +use OCA\Notifications\MailNotifications; +use OCP\AppFramework\Utility\ITimeFactory; + +class SendNotificationMails extends TimedJob { + + /** @var ITimeFactory */ + protected $timeFactory; + /** @var MailNotifications */ + protected $mailNotifications; + + public function __construct(ITimeFactory $timeFactory, MailNotifications $mailNotifications) { + parent::__construct($timeFactory); + + // run every 15 min + $this->setInterval(60 * 15); + + $this->timeFactory = $timeFactory; + $this->mailNotifications = $mailNotifications; + } + + protected function run($argument) { + $this->mailNotifications->sendEmails(); + } +} diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php new file mode 100644 index 0000000..4a4aedb --- /dev/null +++ b/lib/Controller/SettingsController.php @@ -0,0 +1,90 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2021 Julien Barnoin <julien@barnoin.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\Notifications\Controller; + +use OCP\AppFramework\OCSController; +use OCP\AppFramework\Http\DataResponse; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IRequest; +use OCP\IUserSession; + +class SettingsController extends OCSController { + /** @var \OCP\IConfig */ + protected $config; + + /** @var \OCP\IL10N */ + protected $l10n; + + /** @var string */ + protected $user; + + public function __construct(string $appName, + IRequest $request, + IConfig $config, + IL10N $l10n, + IUserSession $userSession) { + parent::__construct($appName, $request); + $this->config = $config; + $this->l10n = $l10n; + $this->user = $userSession->getUser()->getUID(); + } + + /** + * @NoAdminRequired + * + * @param int $notify_setting_batchtime + * @param bool $notifications_email_enabled + * @return DataResponse + */ + public function personal( + int $notify_setting_batchtime = \OCA\Notifications\Settings\Personal::EMAIL_SEND_HOURLY, + bool $notifications_email_enabled = false + ): DataResponse { + $email_batch_time = 3600; + if ($notify_setting_batchtime === \OCA\Notifications\Settings\Personal::EMAIL_SEND_DAILY) { + $email_batch_time = 3600 * 24; + } elseif ($notify_setting_batchtime === \OCA\Notifications\Settings\Personal::EMAIL_SEND_WEEKLY) { + $email_batch_time = 3600 * 24 * 7; + } elseif ($notify_setting_batchtime === \OCA\Notifications\Settings\Personal::EMAIL_SEND_ASAP) { + $email_batch_time = 0; + } + + $this->config->setUserValue( + $this->user, 'notifications', + 'notify_setting_batchtime', + (string) $email_batch_time + ); + $this->config->setUserValue( + $this->user, 'notifications', + 'notifications_email_enabled', + $notifications_email_enabled ? '1' : '0' + ); + + return new DataResponse([ + 'message' => $this->l10n->t('Your settings have been updated.'), + ]); + } +} diff --git a/lib/Handler.php b/lib/Handler.php index 6d92bfd..ff8b982 100644 --- a/lib/Handler.php +++ b/lib/Handler.php @@ -186,6 +186,38 @@ class Handler { } /** + * Get the notifications after (and excluding) the given id + * + * @param int $startAfterId + * @param string $user + * @param int $limit + * @return array [notification_id => INotification] + * @throws NotificationNotFoundException + */ + public function getAfterId(int $startAfterId, string $user, $limit = 25): array { + $sql = $this->connection->getQueryBuilder(); + $sql->select('*') + ->from('notifications') + ->orderBy('notification_id', 'DESC') + ->setMaxResults($limit) + ->where($sql->expr()->gt('notification_id', $sql->createNamedParameter($startAfterId))) + ->andWhere($sql->expr()->eq('user', $sql->createNamedParameter($user))); + $statement = $sql->executeQuery(); + + $notifications = []; + while ($row = $statement->fetch()) { + try { + $notifications[(int)$row['notification_id']] = $this->notificationFromRow($row); + } catch (\InvalidArgumentException $e) { + continue; + } + } + $statement->closeCursor(); + + return $notifications; + } + + /** * Return the notifications matching the given Notification * * @param INotification $notification diff --git a/lib/MailNotifications.php b/lib/MailNotifications.php new file mode 100644 index 0000000..e97f518 --- /dev/null +++ b/lib/MailNotifications.php @@ -0,0 +1,330 @@ +<?php + +declare(strict_types=1); + +/** + * @author Julien Barnoin <julien@barnoin.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\Notifications; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Defaults; +use OCP\IConfig; +use OCP\IDateTimeFormatter; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\L10N\IFactory; +use OCP\Mail\IMailer; +use OCP\Mail\IMessage; +use OCP\Notification\IManager; +use OCP\Notification\INotification; +use OCP\Notification\IAction; +use OCP\Util; +use Psr\Log\LoggerInterface; + +class MailNotifications { + + /** @var IConfig */ + private $config; + + /** @var IManager */ + private $manager; + + /** @var Handler */ + protected $handler; + + /** @var IUserManager */ + private $userManager; + + /** @var LoggerInterface */ + private $logger; + + /** @var IMailer */ + private $mailer; + + /** @var IURLGenerator */ + private $urlGenerator; + + /** @var Defaults */ + private $defaults; + + /** @var IFactory */ + private $l10nFactory; + + /** @var IDateTimeFormatter */ + private $dateFormatter; + + /** @var ITimeFactory */ + protected $timeFactory; + + public const DEFAULT_BATCH_TIME = 3600 * 24; + + public function __construct( + IConfig $config, + IManager $manager, + Handler $handler, + IUserManager $userManager, + LoggerInterface $logger, + IMailer $mailer, + IURLGenerator $urlGenerator, + Defaults $defaults, + IFactory $l10nFactory, + IDateTimeFormatter $dateTimeFormatter, + ITimeFactory $timeFactory + ) { + $this->config = $config; + $this->manager = $manager; + $this->handler = $handler; + $this->userManager = $userManager; + $this->logger = $logger; + $this->mailer = $mailer; + $this->urlGenerator = $urlGenerator; + $this->defaults = $defaults; + $this->l10nFactory = $l10nFactory; + $this->dateFormatter = $dateTimeFormatter; + $this->timeFactory = $timeFactory; + } + + /** + * Send all due notification emails. + */ + public function sendEmails(): void { + // Get all users who enabled notification emails. + $users = $this->config->getUsersForUserValue('notifications', 'notifications_email_enabled', '1'); + + if (count($users) == 0) { + return; + } + + // Batch-read settings that will be used to figure out who needs notifications sent out + $userBatchTimes = $this->config->getUserValueForUsers('notifications', 'notify_setting_batchtime', $users); + $userLastSendIds = $this->config->getUserValueForUsers('notifications', 'mail_last_send_id', $users); + $userLastSendTimes = $this->config->getUserValueForUsers('notifications', 'mail_last_send_time', $users); + + $now = $this->timeFactory->getTime(); + + foreach ($users as $user) { + // Get the settings for this particular user, then check if we have notifications to email them + $batchTime = (array_key_exists($user, $userBatchTimes)) ? (int) $userBatchTimes[$user] : self::DEFAULT_BATCH_TIME; + $lastSendId = (array_key_exists($user, $userLastSendIds)) ? (int) $userLastSendIds[$user] : -1; + $lastSendTime = (array_key_exists($user, $userLastSendTimes)) ? (int) $userLastSendTimes[$user] : -1; + + if (($now - $lastSendTime) >= $batchTime) { + // Enough time passed since last send for the user's desired interval between mails. + $notifications = $this->handler->getAfterId($lastSendId, $user); + if (!empty($notifications)) { + $this->sendEmailToUser($user, $notifications, $now); + } + } + } + } + + /** + * send an email to the user containing given list of notifications + * + * @param string $uid + * @param INotification[] $notifications + * @param int $now + */ + protected function sendEmailToUser(string $uid, array $notifications, int $now): void { + $lastSendId = array_key_first($notifications); + + $language = $this->config->getUserValue($uid, 'core', 'lang', $this->config->getSystemValue('default_language', 'en')); + + $preparedNotifications = []; + foreach ($notifications as $notification) { + /** @var INotification $preparedNotification */ + try { + $preparedNotification = $this->manager->prepare($notification, $language); + } catch (\InvalidArgumentException $e) { + // The app was disabled, skip the notification + continue; + } catch (\Exception $e) { + $this->logger->error($e->getMessage()); + continue; + } + + $preparedNotifications[] = $preparedNotification; + } + + if (count($preparedNotifications) > 0) { + $message = $this->prepareEmailMessage($uid, $preparedNotifications, $language); + + if ($message != null) { + try { + $this->mailer->send($message); + + // This is handled in config values based on how 'activity_digest_last_send' works, + // but it would likely be a better choice to have this stored in a db like the activity mail queue? + $this->config->setUserValue($uid, 'notifications', 'mail_last_send_id', (string)$lastSendId); + $this->config->setUserValue($uid, 'notifications', 'mail_last_send_time', (string)$now); + } catch (\Exception $e) { + $this->logger->error($e->getMessage()); + } + } + } + } + + /** + * prepare the contents of the email message containing the provided list of notifications + * + * @param string $uid + * @param INotification[] $notifications + * @param string $language + * @return ?IMessage message contents + */ + protected function prepareEmailMessage(string $uid, array $notifications, string $language): ?IMessage { + $user = $this->userManager->get($uid); + $userEmailAddress = $user->getEMailAddress(); + + if (empty($userEmailAddress)) { + return null; + } + + // Prepare our email template + $l10n = $this->l10nFactory->get('notifications', $language); + + $template = $this->mailer->createEMailTemplate('notifications.EmailNotification', [ + 'displayname' => $user->getDisplayName(), + 'url' => $this->urlGenerator->getAbsoluteURL('/') + ]); + + // Prepare email header + $template->addHeader(); + $template->addHeading($l10n->t('Hello %s',[$user->getDisplayName()]), $l10n->t('Hello %s,',[$user->getDisplayName()])); + + // Prepare email subject and body mentioning amount of notifications + $homeLink = '<a href="' . $this->urlGenerator->getAbsoluteURL('/') . '">' . htmlspecialchars($this->defaults->getName()) . '</a>'; + $notificationsCount = count($notifications); + $template->setSubject($l10n->n('New notification for %s', '%n new notifications for %s', $notificationsCount, [$this->defaults->getName()])); + $template->addBodyText( + $l10n->n('You have a new notification for %s', 'You have %n new notifications for %s', $notificationsCount, [$homeLink]), + $l10n->n('You have a new notification for %s', 'You have %n new notifications for %s', $notificationsCount, [$this->urlGenerator->getAbsoluteURL('/')]) + ); + + // Prepare email body with the content of missed notifications + // Notifications are assumed to be passed in in descending order (latest first). Reversing to present chronologically. + $notifications = array_reverse($notifications); + + $timezone = $this->config->getUserValue($uid, 'core', 'timezone', date_default_timezone_get()); + + foreach ($notifications as $notification) { + $relativeDateTime = $this->dateFormatter->formatDateTimeRelativeDay($notification->getDateTime(), 'long', 'short', new \DateTimeZone($timezone), $l10n); + $template->addBodyListItem($this->getHTMLContents($notification), $relativeDateTime, $notification->getIcon(), $notification->getParsedSubject()); + + // Buttons probably were not intended for this, but it works ok enough for showing the idea. + $actions = $notification->getParsedActions(); + foreach ($actions as $action) { + if ($action->getRequestType() === IAction::TYPE_WEB) { + $template->addBodyButton($action->getLabel(), $action->getLink()); + } + } + } + + // Prepare email footer + $template->addBodyText( + $l10n->t('You can change the frequency of these emails or disable them <a href="%s">here</a>.', $this->urlGenerator->linkToRouteAbsolute('settings.PersonalSettings.index', ['section' => 'notifications'])), + $l10n->t('You can change the frequency of these emails or disable them here: %s', $this->urlGenerator->linkToRouteAbsolute('settings.PersonalSettings.index', ['section' => 'notifications'])) + ); + + $template->addFooter(); + + $message = $this->mailer->createMessage(); + $message->useTemplate($template); + $message->setTo([$userEmailAddress => $user->getDisplayName()]); + $message->setFrom([Util::getDefaultEmailAddress('no-reply') => $this->defaults->getName()]); + + return $message; + } + + /** + * return HTML to display this notification + * + * @param INotification $notification + * @return string + */ + protected function getHTMLContents(INotification $notification): string { + $HTMLSubject = $this->getHTMLSubject($notification); + $link = $notification->getLink(); + if ($link !== '') { + $HTMLSubject = '<a href="' . $link . '">' . $HTMLSubject . '</a>'; + } + + return $HTMLSubject . '<br>' . $this->getHTMLMessage($notification); + } + + /** + * return HTML to display the subject of this notification + * + * @param INotification $notification + * @return string + */ + protected function getHTMLSubject(INotification $notification): string { + $contentString = htmlspecialchars($notification->getRichSubject()); + if ($contentString === '') { + return htmlspecialchars($notification->getParsedSubject()); + } + + return $this->replaceRichParameters($notification->getRichSubjectParameters(), $contentString); + } + + /** + * return HTML to display the message body of this notification + * + * @param INotification $notification + * @return string + */ + protected function getHTMLMessage(INotification $notification): string { + $contentString = htmlspecialchars($notification->getRichMessage()); + if ($contentString === '') { + return htmlspecialchars($notification->getParsedMessage()); + } + + return $this->replaceRichParameters($notification->getRichMessageParameters(), $contentString); + } + + /** + * replace the given parameters in the input content string for display in an email + * + * @param array [string => string] $parameters + * @param string $contentString + * @return string $contentString with parameters processed + */ + protected function replaceRichParameters(array $parameters, string $contentString): string { + $placeholders = $replacements = []; + foreach ($parameters as $placeholder => $parameter) { + $placeholders[] = '{' . $placeholder . '}'; + + if ($parameter['type'] === 'file') { + $replacement = $parameter['path']; + } else { + $replacement = $parameter['name']; + } + + if (isset($parameter['link'])) { + $replacements[] = '<a href="' . $parameter['link'] . '">' . htmlspecialchars($replacement) . '</a>'; + } else { + $replacements[] = '<strong>' . htmlspecialchars($replacement) . '</strong>'; + } + } + + return str_replace($placeholders, $replacements, $contentString); + } +} diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php new file mode 100644 index 0000000..5e7aaa6 --- /dev/null +++ b/lib/Settings/Personal.php @@ -0,0 +1,112 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2021 Julien Barnoin <julien@barnoin.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\Notifications\Settings; + +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IConfig; +use OCP\IL10N; +use OCP\Settings\ISettings; +use OCP\IUserSession; +use OCP\IInitialStateService; +use OCP\Util; + +class Personal implements ISettings { + /** @var \OCP\IConfig */ + protected $config; + + /** @var \OCP\IL10N */ + protected $l10n; + + /** @var IUserSession */ + private $session; + + /** @var IInitialStateService */ + private $initialStateService; + + public const EMAIL_SEND_HOURLY = 0; + public const EMAIL_SEND_DAILY = 1; + public const EMAIL_SEND_WEEKLY = 2; + public const EMAIL_SEND_ASAP = 3; + + public function __construct(IConfig $config, + IL10N $l10n, + IUserSession $session, + IInitialStateService $initialStateService) { + $this->config = $config; + $this->l10n = $l10n; + + $this->session = $session; + $this->initialStateService = $initialStateService; + } + + /** + * @return TemplateResponse + */ + public function getForm(): TemplateResponse { + Util::addScript('notifications', 'notifications-userSettings'); + + $settingBatchTime = Personal::EMAIL_SEND_HOURLY; + $user = $this->session->getUser()->getUID(); + $currentSetting = (int) $this->config->getUserValue($user, 'notifications', 'notify_setting_batchtime', 3600 * 24); + + if ($currentSetting === 3600 * 24 * 7) { + $settingBatchTime = Personal::EMAIL_SEND_WEEKLY; + } elseif ($currentSetting === 3600 * 24) { + $settingBatchTime = Personal::EMAIL_SEND_DAILY; + } elseif ($currentSetting === 0) { + $settingBatchTime = Personal::EMAIL_SEND_ASAP; + } + + $emailEnabled = true; + + $this->initialStateService->provideInitialState('notifications', 'config', [ + 'setting' => 'personal', + 'is_email_set' => !empty($this->config->getUserValue($user, 'settings', 'email', '')), + 'email_enabled' => $emailEnabled, + 'setting_batchtime' => $settingBatchTime, + 'notifications_email_enabled' => $this->config->getUserValue($user, 'notifications', 'notifications_email_enabled') == 1 + ]); + + return new TemplateResponse('notifications', 'settings/personal'); + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection(): string { + return 'notifications'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority(): int { + return 55; + } +} diff --git a/lib/Settings/PersonalSection.php b/lib/Settings/PersonalSection.php new file mode 100644 index 0000000..16b544a --- /dev/null +++ b/lib/Settings/PersonalSection.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2021 Julien Barnoin <julien@barnoin.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\Notifications\Settings; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class PersonalSection implements IIconSection { + /** @var IL10N */ + private $l; + + /** @var IURLGenerator */ + private $url; + + public function __construct(IURLGenerator $url, IL10N $l) { + $this->url = $url; + $this->l = $l; + } + + /** + * returns the relative path to an 16*16 icon describing the section. + * e.g. '/core/img/places/files.svg' + * + * @returns string + * @since 12 + */ + public function getIcon(): string { + return $this->url->imagePath('notifications', 'notifications-dark.svg'); + } + + /** + * returns the ID of the section. It is supposed to be a lower case string, + * e.g. 'ldap' + * + * @returns string + * @since 9.1 + */ + public function getID(): string { + return 'notifications'; + } + + /** + * returns the translated name as it should be displayed, e.g. 'LDAP / AD + * integration'. Use the L10N service to translate it. + * + * @return string + * @since 9.1 + */ + public function getName(): string { + return $this->l->t('Notifications'); + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the settings navigation. The sections are arranged in ascending order of + * the priority values. It is required to return a value between 0 and 99. + * + * E.g.: 70 + * @since 9.1 + */ + public function getPriority(): int { + return 10; + } +} |