diff --git a/appinfo/info.xml b/appinfo/info.xml index 5d578679a..4b75b06a7 100755 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -14,7 +14,7 @@ More information is available in the Activity documentation. - 2.13.0 + 2.13.1 agpl Frank Karlitschek Joas Schilling @@ -42,6 +42,7 @@ OCA\Activity\BackgroundJob\EmailNotification OCA\Activity\BackgroundJob\ExpireActivities + OCA\Activity\BackgroundJob\DigestMail diff --git a/lib/BackgroundJob/DigestMail.php b/lib/BackgroundJob/DigestMail.php new file mode 100644 index 000000000..558f72560 --- /dev/null +++ b/lib/BackgroundJob/DigestMail.php @@ -0,0 +1,49 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Activity\BackgroundJob; + + +use OC\BackgroundJob\TimedJob; +use OCA\Activity\DigestSender; +use OCP\AppFramework\Utility\ITimeFactory; + +class DigestMail extends TimedJob { + + /** @var DigestSender */ + protected $digestSender; + /** @var ITimeFactory */ + protected $timeFactory; + + public function __construct(DigestSender $digestSender, ITimeFactory $timeFactory) { + // run hourly + $this->setInterval(60 * 60); + + $this->digestSender = $digestSender; + $this->timeFactory = $timeFactory; + } + + protected function run($argument) { + $this->digestSender->sendDigests($this->timeFactory->getTime()); + } +} diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 8173b56b8..fe1ae7b86 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -97,12 +97,15 @@ public function __construct($appName, * @param int $notify_setting_batchtime * @param bool $notify_setting_self * @param bool $notify_setting_selfemail + * @param bool $activity_digest * @return DataResponse */ public function personal( $notify_setting_batchtime = UserSettings::EMAIL_SEND_HOURLY, $notify_setting_self = false, - $notify_setting_selfemail = false) { + $notify_setting_selfemail = false, + $activity_digest = false + ) { $settings = $this->manager->getSettings(); foreach ($settings as $setting) { @@ -145,6 +148,11 @@ public function personal( 'notify_setting_selfemail', (int) $notify_setting_selfemail ); + $this->config->setUserValue( + $this->user, 'activity', + 'notify_setting_activity_digest', + (int) $activity_digest + ); return new DataResponse(array( 'data' => array( diff --git a/lib/Data.php b/lib/Data.php index 65f8752ae..eeed4d259 100755 --- a/lib/Data.php +++ b/lib/Data.php @@ -88,14 +88,14 @@ public function send(IEvent $event): int { 'type' => $event->getType(), 'affecteduser' => $event->getAffectedUser(), 'user' => $event->getAuthor(), - 'timestamp' => (int) $event->getTimestamp(), + 'timestamp' => (int)$event->getTimestamp(), 'subject' => $event->getSubject(), 'subjectparams' => json_encode($event->getSubjectParameters()), 'message' => $event->getMessage(), 'messageparams' => json_encode($event->getMessageParameters()), 'priority' => IExtension::PRIORITY_MEDIUM, 'object_type' => $event->getObjectType(), - 'object_id' => (int) $event->getObjectId(), + 'object_id' => (int)$event->getObjectId(), 'object_name' => $event->getObjectName(), 'link' => $event->getLink(), ]) @@ -108,7 +108,7 @@ public function send(IEvent $event): int { * Send an event as email * * @param IEvent $event - * @param int $latestSendTime Activity $timestamp + batch setting of $affectedUser + * @param int $latestSendTime Activity $timestamp + batch setting of $affectedUser * @return bool */ public function storeMail(IEvent $event, int $latestSendTime): bool { @@ -124,7 +124,7 @@ public function storeMail(IEvent $event, int $latestSendTime): bool { 'amq_subject' => $query->createNamedParameter($event->getSubject()), 'amq_subjectparams' => $query->createNamedParameter(json_encode($event->getSubjectParameters())), 'amq_affecteduser' => $query->createNamedParameter($affectedUser), - 'amq_timestamp' => $query->createNamedParameter((int) $event->getTimestamp()), + 'amq_timestamp' => $query->createNamedParameter((int)$event->getTimestamp()), 'amq_type' => $query->createNamedParameter($event->getType()), 'amq_latest_send' => $query->createNamedParameter($latestSendTime), 'object_type' => $query->createNamedParameter($event->getObjectType()), @@ -150,12 +150,11 @@ public function storeMail(IEvent $event, int $latestSendTime): bool { * @param string $objectType Allows to filter the activities to a given object. May only appear together with $objectId * @param int $objectId Allows to filter the activities to a given object. May only appear together with $objectType * + * @param bool $returnEvents return only the events * @return array * - * @throws \OutOfBoundsException if the user (Code: 1) or the since (Code: 2) is invalid - * @throws \BadMethodCallException if the user has selected to display no types for this filter (Code: 3) */ - public function get(GroupHelper $groupHelper, UserSettings $userSettings, $user, $since, $limit, $sort, $filter, $objectType = '', $objectId = 0) { + public function get(GroupHelper $groupHelper, UserSettings $userSettings, $user, $since, $limit, $sort, $filter, $objectType = '', $objectId = 0, bool $returnEvents = false) { // get current user if ($user === '') { throw new \OutOfBoundsException('Invalid user', 1); @@ -252,13 +251,17 @@ public function get(GroupHelper $groupHelper, UserSettings $userSettings, $user, $hasMore = true; break; } - $headers['X-Activity-Last-Given'] = (int) $row['activity_id']; + $headers['X-Activity-Last-Given'] = (int)$row['activity_id']; $groupHelper->addActivity($row); $limit--; } $result->closeCursor(); - return ['data' => $groupHelper->getActivities(), 'has_more' => $hasMore, 'headers' => $headers]; + if ($returnEvents) { + return $groupHelper->getEvents(); + } else { + return ['data' => $groupHelper->getActivities(), 'has_more' => $hasMore, 'headers' => $headers]; + } } /** @@ -276,7 +279,7 @@ protected function setOffsetFromSince(IQueryBuilder $query, $user, $since, $sort $queryBuilder = $this->connection->getQueryBuilder(); $queryBuilder->select(['affecteduser', 'timestamp']) ->from('activity') - ->where($queryBuilder->expr()->eq('activity_id', $queryBuilder->createNamedParameter((int) $since))); + ->where($queryBuilder->expr()->eq('activity_id', $queryBuilder->createNamedParameter((int)$since))); $result = $queryBuilder->execute(); $activity = $result->fetch(); $result->closeCursor(); @@ -285,7 +288,7 @@ protected function setOffsetFromSince(IQueryBuilder $query, $user, $since, $sort if ($activity['affecteduser'] !== $user) { throw new \OutOfBoundsException('Invalid since', 2); } - $timestamp = (int) $activity['timestamp']; + $timestamp = (int)$activity['timestamp']; if ($sort === 'DESC') { $query->andWhere($query->expr()->lte('timestamp', $query->createNamedParameter($timestamp))); @@ -313,7 +316,7 @@ protected function setOffsetFromSince(IQueryBuilder $query, $user, $since, $sort if ($activity !== false) { return [ - 'X-Activity-First-Known' => (int) $activity['activity_id'], + 'X-Activity-First-Known' => (int)$activity['activity_id'], ]; } @@ -353,9 +356,9 @@ public function expire($expireDays = 365) { $ttl = (60 * 60 * 24 * max(1, $expireDays)); $timelimit = time() - $ttl; - $this->deleteActivities(array( - 'timestamp' => array($timelimit, '<'), - )); + $this->deleteActivities([ + 'timestamp' => [$timelimit, '<'], + ]); } /** @@ -367,7 +370,7 @@ public function expire($expireDays = 365) { */ public function deleteActivities($conditions) { $sqlWhere = ''; - $sqlParameters = $sqlWhereList = array(); + $sqlParameters = $sqlWhereList = []; foreach ($conditions as $column => $comparison) { $sqlWhereList[] = " `$column` " . ((is_array($comparison) && isset($comparison[1])) ? $comparison[1] : '=') . ' ? '; $sqlParameters[] = (is_array($comparison)) ? $comparison[0] : $comparison; @@ -379,7 +382,7 @@ public function deleteActivities($conditions) { // Add galera safe delete chunking if using mysql // Stops us hitting wsrep_max_ws_rows when large row counts are deleted - if($this->connection->getDatabasePlatform() instanceof MySqlPlatform) { + if ($this->connection->getDatabasePlatform() instanceof MySqlPlatform) { // Then use chunked delete $max = 100000; $query = $this->connection->prepare( @@ -387,7 +390,7 @@ public function deleteActivities($conditions) { do { $query->execute($sqlParameters); $deleted = $query->rowCount(); - } while($deleted === $max); + } while ($deleted === $max); } else { // Dont use chunked delete - let the DB handle the large row count natively $query = $this->connection->prepare( @@ -406,19 +409,63 @@ public function getById(int $activityId): ?IEvent { $hasMore = false; if ($row = $result->fetch()) { $event = $this->activityManager->generateEvent(); - $event->setApp((string) $row['app']) - ->setType((string) $row['type']) - ->setAffectedUser((string) $row['affecteduser']) - ->setAuthor((string) $row['user']) - ->setTimestamp((int) $row['timestamp']) - ->setSubject((string) $row['subject'], (array) json_decode($row['subjectparams'], true)) - ->setMessage((string) $row['message'], (array) json_decode($row['messageparams'], true)) - ->setObject((string) $row['object_type'], (int) $row['object_id'], (string) $row['file']) - ->setLink((string) $row['link']); + $event->setApp((string)$row['app']) + ->setType((string)$row['type']) + ->setAffectedUser((string)$row['affecteduser']) + ->setAuthor((string)$row['user']) + ->setTimestamp((int)$row['timestamp']) + ->setSubject((string)$row['subject'], (array)json_decode($row['subjectparams'], true)) + ->setMessage((string)$row['message'], (array)json_decode($row['messageparams'], true)) + ->setObject((string)$row['object_type'], (int)$row['object_id'], (string)$row['file']) + ->setLink((string)$row['link']); return $event; } else { return null; } } + + /** + * Get the id of the first activity in the stream since a specified time + * + * @param string $user + * @param int $timestamp + * @return int + */ + public function getFirstActivitySince(string $user, int $timestamp): int { + $query = $this->connection->getQueryBuilder(); + $query->select('activity_id') + ->from('activity') + ->where($query->expr()->eq('affecteduser', $query->createNamedParameter($user))) + ->andWhere($query->expr()->gt('timestamp', $query->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT))) + ->orderBy('timestamp', 'ASC') + ->setMaxResults(1); + + $res = $query->execute()->fetch(\PDO::FETCH_COLUMN); + return (int)$res; + } + + /** + * Get the number of activity items and the latest activity id since the specified activity + * + * @param string $user + * @param int $since + * @param bool $byOthers + * @return array + */ + public function getActivitySince(string $user, int $since, bool $byOthers) { + $query = $this->connection->getQueryBuilder(); + $nameParam = $query->createNamedParameter($user); + $query->select($query->func()->count('activity_id', 'count')) + ->selectAlias($query->func()->max('activity_id'), 'max') + ->from('activity') + ->where($query->expr()->eq('affecteduser', $nameParam)) + ->andWhere($query->expr()->gt('activity_id', $query->createNamedParameter($since, IQueryBuilder::PARAM_INT))); + + if ($byOthers) { + $query->andWhere($query->expr()->neq('user', $nameParam)); + } + + return $query->execute()->fetch(); + } } diff --git a/lib/DigestSender.php b/lib/DigestSender.php new file mode 100644 index 000000000..13e677172 --- /dev/null +++ b/lib/DigestSender.php @@ -0,0 +1,230 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Activity; + +use OCP\Activity\IEvent; +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\Util; +use Psr\Log\LoggerInterface; + +class DigestSender { + public const ACTIVITY_LIMIT = 20; + + private $config; + private $data; + private $userSettings; + private $groupHelper; + private $mailer; + private $userManager; + private $urlGenerator; + private $defaults; + private $l10nFactory; + private $dateFormatter; + private $logger; + + public function __construct( + IConfig $config, + Data $data, + UserSettings $userSettings, + GroupHelper $groupHelper, + IMailer $mailer, + IUserManager $userManager, + IURLGenerator $urlGenerator, + Defaults $defaults, + IFactory $l10nFactory, + IDateTimeFormatter $dateTimeFormatter, + LoggerInterface $logger + ) { + $this->config = $config; + $this->data = $data; + $this->userSettings = $userSettings; + $this->groupHelper = $groupHelper; + $this->mailer = $mailer; + $this->userManager = $userManager; + $this->urlGenerator = $urlGenerator; + $this->defaults = $defaults; + $this->l10nFactory = $l10nFactory; + $this->dateFormatter = $dateTimeFormatter; + $this->logger = $logger; + } + + public function sendDigests(int $now): void { + $users = $this->getDigestUsers(); + $userLanguages = $this->config->getUserValueForUsers('core', 'lang', $users); + $userTimezones = $this->config->getUserValueForUsers('core', 'timezone', $users); + $digestDate = $this->config->getUserValueForUsers('activity', 'digest', $users); + $defaultLanguage = $this->config->getSystemValue('default_language', 'en'); + $defaultTimeZone = date_default_timezone_get(); + $timezoneDigestDay = []; + + foreach ($users as $user) { + $language = (!empty($userLanguages[$user])) ? $userLanguages[$user] : $defaultLanguage; + $timezone = (!empty($userTimezones[$user])) ? $userTimezones[$user] : $defaultTimeZone; + + // Check if the user's timezone is after 6am already + if (!isset($timezoneDigestDay[$timezone])) { + $timezoneDate = new \DateTime('now', new \DateTimeZone($timezone)); + if ($timezoneDate->format('H') < 6) { + // Still before 6am, so dont send yet. + $timezoneDate->sub(new \DateInterval('P1D')); + } + $timezoneDigestDay[$timezone] = $timezoneDate->format('Y.m.d'); + } + + $userDigestDate = $digestDate[$user] ?? ''; + if ($userDigestDate === $timezoneDigestDay[$timezone]) { + // User got todays digest already + continue; + } + + $this->sendDigestForUser($user, $now, $timezone, $language); + $this->config->setUserValue($user, 'activity', 'digest', $timezoneDigestDay[$timezone]); + } + } + + /** + * get all users who have activity digest enabled + * + * @return string[] + */ + private function getDigestUsers(): array { + return $this->config->getUsersForUserValue('activity', 'notify_setting_activity_digest', 1); + } + + private function getLastSendActivity(string $user, int $now): int { + $lastSend = (int)$this->config->getUserValue($user, 'activity', 'activity_digest_last_send', 0); + if ($lastSend > 0) { + return $lastSend; + } + + // Don't flood on first email with old news, just consider the last 24h + return $this->data->getFirstActivitySince($user, $now - (24 * 60 * 60)); + } + + public function sendDigestForUser(string $uid, int $now, string $timezone, string $language) { + $l10n = $this->l10nFactory->get('activity', $language); + $lastSend = $this->getLastSendActivity($uid, $now); + $user = $this->userManager->get($uid); + if ($lastSend === 0) { + return; + } + + ['count' => $count, 'max' => $lastActivityId] = $this->data->getActivitySince($uid, $lastSend, false); + $count = (int) $count; + $lastActivityId = (int) $lastActivityId; + if ($count === 0) { + return; + } + + /** @var IEvent[] $activities */ + $activities = $this->data->get( + $this->groupHelper, + $this->userSettings, + $uid, + $lastSend, + self::ACTIVITY_LIMIT, + 'asc', + 'by', + '', + 0, + true + ); + $skippedCount = max(0, $count - self::ACTIVITY_LIMIT); + + $template = $this->mailer->createEMailTemplate('activity.Notification', [ + 'displayname' => $user->getDisplayName(), + 'url' => $this->urlGenerator->getAbsoluteURL('/'), + 'activityEvents' => $activities, + 'skippedCount' => $skippedCount, + ]); + $template->setSubject($l10n->t('Daily activity summary for ' . $this->defaults->getName())); + $template->addHeader(); + + foreach ($activities as $event) { + $relativeDateTime = $this->dateFormatter->formatDateTimeRelativeDay( + $event->getTimestamp(), + 'long', + 'short', + new \DateTimeZone($timezone), + $l10n + ); + + $template->addBodyListItem($this->getHTMLSubject($event), $relativeDateTime, $event->getIcon(), $event->getParsedSubject()); + } + + if ($skippedCount) { + $template->addBodyListItem($l10n->n('and %n more ', 'and %n more ', $skippedCount)); + } + + $template->addFooter(); + + $message = $this->mailer->createMessage(); + $message->setTo([$user->getEMailAddress() => $user->getDisplayName()]); + $message->useTemplate($template); + $message->setFrom([Util::getDefaultEmailAddress('no-reply') => $this->defaults->getName()]); + + try { + $this->mailer->send($message); + $this->config->setUserValue($user->getUID(), 'activity', 'activity_digest_last_send', $lastActivityId); + } catch (\Exception $e) { + $this->logger->error($e->getMessage()); + return; + } + } + + /** + * @param IEvent $event + * @return string + */ + protected function getHTMLSubject(IEvent $event): string { + if ($event->getRichSubject() === '') { + return htmlspecialchars($event->getParsedSubject()); + } + + $placeholders = $replacements = []; + foreach ($event->getRichSubjectParameters() as $placeholder => $parameter) { + $placeholders[] = '{' . $placeholder . '}'; + + if ($parameter['type'] === 'file') { + $replacement = $parameter['path']; + } else { + $replacement = $parameter['name']; + } + + if (isset($parameter['link'])) { + $replacements[] = '' . htmlspecialchars($replacement) . ''; + } else { + $replacements[] = '' . htmlspecialchars($replacement) . ''; + } + } + + return str_replace($placeholders, $replacements, $event->getRichSubject()); + } +} diff --git a/lib/GroupHelper.php b/lib/GroupHelper.php index c9766a4ad..fc5119e5a 100644 --- a/lib/GroupHelper.php +++ b/lib/GroupHelper.php @@ -139,6 +139,13 @@ public function getActivities() { return $return; } + /** + * @return IEvent[] + */ + public function getEvents(): array { + return $this->event; + } + /** * @param array $row * @return IEvent diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php index cd9408c10..5bd866dbc 100644 --- a/lib/Settings/Personal.php +++ b/lib/Settings/Personal.php @@ -152,6 +152,8 @@ public function getForm() { 'notify_selfemail' => $this->userSettings->getUserSetting($this->user, 'setting', 'selfemail'), 'methods' => $methods, + + 'activity_digest_enabled' => $this->userSettings->getUserSetting($this->user, 'setting', 'activity_digest') ], 'blank'); } diff --git a/templates/settings/form.php b/templates/settings/form.php index b59d85771..ddab62d1e 100644 --- a/templates/settings/form.php +++ b/templates/settings/form.php @@ -69,7 +69,7 @@
- + /> +